Compare commits
43 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e8d77f3779 | |||
| 6900c20734 | |||
| f96aba9d99 | |||
| 78dade0d86 | |||
| d819809197 | |||
| dadae8f60a | |||
| 99982b12d9 | |||
| 75b21acf87 | |||
| b16540099d | |||
| b0855ccd9b | |||
| 87dee93b86 | |||
| fe883047e0 | |||
| 20a0bbcd75 | |||
| ed0c278a8b | |||
| 094c881798 | |||
| bc87f10661 | |||
| 562c924809 | |||
| 94a5e81555 | |||
| 94f2260f43 | |||
| 994fda5cc7 | |||
| 2a7da33160 | |||
| 0a38fc60f2 | |||
| 61700df17e | |||
| ae21f6c64a | |||
| bef8d04d72 | |||
| d1587b785a | |||
| 1e1f76a000 | |||
| 06672589ad | |||
| d20eea6877 | |||
| c49c56bc97 | |||
| e90cb788ba | |||
| 010fe14ab4 | |||
| b344506071 | |||
| 7f6330a617 | |||
| fccac400a4 | |||
| e5a1e1b781 | |||
| f6e2d0923b | |||
| 9d3ec58f0c | |||
| 614d5d96c9 | |||
| 30a08c787c | |||
| b37b5291ae | |||
| d2c66b6316 | |||
| e515080171 |
23
.gitignore
vendored
23
.gitignore
vendored
@@ -3,8 +3,25 @@ __pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
.idea/
|
||||
dist/
|
||||
.run/
|
||||
release-keys/
|
||||
config/local.toml
|
||||
results/*
|
||||
!results/.gitkeep
|
||||
|
||||
# Local development, investigation, and release tooling. The production
|
||||
# repository intentionally contains only the installer/runtime sources.
|
||||
AGENTS.md
|
||||
AIO_INSTALLER.md
|
||||
BOOTSTRAP_WORKPLAN.md
|
||||
DEBUGGING.md
|
||||
GITEA_UPDATES.md
|
||||
README.md
|
||||
config/
|
||||
configs/
|
||||
findings/
|
||||
replay_pid*.log
|
||||
results/
|
||||
testcases/
|
||||
tests/
|
||||
tools/
|
||||
dist/
|
||||
/native/
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
# PristonTale EU for Linux
|
||||
|
||||
Version 0.5.2 installs, updates, repairs, and launches PristonTale EU through a
|
||||
Version 0.8.1 installs, updates, repairs, and launches PristonTale EU through a
|
||||
native Linux dashboard. Game files come directly from the publisher and are not
|
||||
included in this archive.
|
||||
|
||||
## Install
|
||||
|
||||
Keep the archive and checksum file together, then run:
|
||||
Keep the archive, detached signature, checksum, and
|
||||
`launcher-update-public.pem` together. First confirm the public-key fingerprint
|
||||
through an independent trusted channel; the expected DER SHA-256 fingerprint is
|
||||
`3ae6e8dc483b93796b32e3ca270c367d247caf8d047eb796826c8e558a4a9e34`.
|
||||
Then run:
|
||||
|
||||
```text
|
||||
sha256sum -c pristontale-eu-linux-installer-v0-5-2.tar.gz.sha256
|
||||
tar -xzf pristontale-eu-linux-installer-v0-5-2.tar.gz
|
||||
cd pristontale-eu-linux-installer-v0-5-2
|
||||
openssl pkey -pubin -in launcher-update-public.pem -outform DER | sha256sum
|
||||
openssl dgst -sha256 -verify launcher-update-public.pem \
|
||||
-signature pristontale-eu-linux-installer-v0-8-1.tar.gz.sig \
|
||||
pristontale-eu-linux-installer-v0-8-1.tar.gz
|
||||
sha256sum -c pristontale-eu-linux-installer-v0-8-1.tar.gz.sha256
|
||||
tar -xzf pristontale-eu-linux-installer-v0-8-1.tar.gz
|
||||
cd pristontale-eu-linux-installer-v0-8-1
|
||||
./install.sh
|
||||
```
|
||||
|
||||
@@ -26,6 +34,11 @@ Existing installations at the previous default location are preserved during
|
||||
upgrades. The installer adds **PristonTale EU** to the application menu and
|
||||
installs the `pristontale-eu` command in `~/.local/bin`.
|
||||
|
||||
Steam Proton 7.0 is discovered in both the default Steam library and secondary
|
||||
libraries registered with the Steam client. If Proton uses an unregistered
|
||||
manual layout, open **Settings → Optional Overrides** and select the exact
|
||||
`Proton 7.0/proton` script.
|
||||
|
||||
## Use
|
||||
|
||||
Open **PristonTale EU** from the application menu or run:
|
||||
@@ -37,11 +50,6 @@ pristontale-eu gui
|
||||
The main launcher button automatically shows **Install**, **Update**, or
|
||||
**Play** according to the managed game's state.
|
||||
|
||||
Version 0.5.0 is the self-update bootstrap. Future signed launcher releases are
|
||||
checked in the background and appear as an **Update Launcher** action. Launcher
|
||||
updates are staged separately from game files and retain the previous version
|
||||
for rollback.
|
||||
|
||||
## Commands
|
||||
|
||||
```text
|
||||
@@ -49,16 +57,22 @@ pristontale-eu --help Show all commands and options
|
||||
pristontale-eu gui Open the native dashboard
|
||||
pristontale-eu doctor Check required host components
|
||||
pristontale-eu login-diagnostics Check the login route without credentials
|
||||
pristontale-eu login-diagnostics --runtime proton7
|
||||
Compare the direct-Proton prefix read-only
|
||||
pristontale-eu login-trace Retired; refuses without starting the game
|
||||
pristontale-eu update Install or update official files
|
||||
pristontale-eu update --repair Verify and repair every game file
|
||||
pristontale-eu update --dry-run Preview an update
|
||||
pristontale-eu import-game "/path/to/game" Import an existing installation
|
||||
pristontale-eu prepare Build the safe runtime view
|
||||
pristontale-eu play Launch through Gamescope/Proton
|
||||
pristontale-eu play --no-box Launch without Gamescope
|
||||
pristontale-eu play Launch through the managed Proton window
|
||||
pristontale-eu play --no-box Launch without X11 window styling
|
||||
pristontale-eu play --no-world-fix Compatibility alias for the pristine launch
|
||||
pristontale-eu settings Show current settings
|
||||
pristontale-eu settings --init Write default settings
|
||||
pristontale-eu settings --init Create/migrate settings without resetting choices
|
||||
pristontale-eu settings --reset Explicitly restore every launcher default
|
||||
pristontale-eu launcher-update --check Check the signed launcher channel
|
||||
pristontale-eu launcher-update --verify Validate the full release without installing it
|
||||
pristontale-eu launcher-update Install and activate an update
|
||||
pristontale-eu launcher-update --rollback Restore the previous launcher
|
||||
pristontale-eu sync Sync without import discovery
|
||||
@@ -66,7 +80,6 @@ pristontale-eu wine-update Run the legacy WPF updater
|
||||
pristontale-eu fetch Fetch the publisher launcher
|
||||
pristontale-eu setup Prepare its Wine environment
|
||||
pristontale-eu cache-compatibility PATH Cache a verified compatibility pair
|
||||
pristontale-eu cache-runtime-patch PATH Cache a verified runtime patch
|
||||
```
|
||||
|
||||
Use `./install.sh --dry-run` to preview dependency installation or
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
cd0314c9fba1779dac5bbe24190c174941dbed89c371b68808fefa37de7750bf pristontale-eu-linux-installer-v0-5-2.tar.gz
|
||||
BIN
dist/pristontale-eu-linux-installer-v0-5-2.zip
vendored
BIN
dist/pristontale-eu-linux-installer-v0-5-2.zip
vendored
Binary file not shown.
BIN
dist/pristontale-eu-linux-update-v0.5.2.tar.gz
vendored
BIN
dist/pristontale-eu-linux-update-v0.5.2.tar.gz
vendored
Binary file not shown.
@@ -1 +0,0 @@
|
||||
e391accd5448f3239aeda2561e5faf2eb17b5aa5c61837da609f456815398002 pristontale-eu-linux-update-v0.5.2.tar.gz
|
||||
11
dist/stable.json
vendored
11
dist/stable.json
vendored
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"archive_sha256": "e391accd5448f3239aeda2561e5faf2eb17b5aa5c61837da609f456815398002",
|
||||
"archive_size": 71737,
|
||||
"archive_url": "https://git.m4ko.net/m4ko/pristontale-eu-linux/releases/download/v0.5.2/pristontale-eu-linux-update-v0.5.2.tar.gz",
|
||||
"channel": "stable",
|
||||
"minimum_updater_version": "0.5.0",
|
||||
"notes_url": "https://git.m4ko.net/m4ko/pristontale-eu-linux/releases/tag/v0.5.2",
|
||||
"published_at": "2026-08-27T19:01:33Z",
|
||||
"schema": 1,
|
||||
"version": "0.5.2"
|
||||
}
|
||||
BIN
dist/stable.json.sig
vendored
BIN
dist/stable.json.sig
vendored
Binary file not shown.
75
install.sh
75
install.sh
@@ -3,12 +3,18 @@
|
||||
set -euo pipefail
|
||||
|
||||
SOURCE_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
VERSION="${PTLINUX_VERSION:-0.5.2}"
|
||||
SOURCE_VERSION="$(sed -n 's/^__version__ = "\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\)"$/\1/p' "${SOURCE_ROOT}/ptlinux/__init__.py")"
|
||||
VERSION="${PTLINUX_VERSION:-$SOURCE_VERSION}"
|
||||
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]
|
||||
then
|
||||
printf 'Invalid launcher version: %s (expected X.Y.Z)\n' "$VERSION" >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ "$VERSION" != "$SOURCE_VERSION" ]]
|
||||
then
|
||||
printf 'Launcher version %s does not match source version %s.\n' "$VERSION" "$SOURCE_VERSION" >&2
|
||||
exit 2
|
||||
fi
|
||||
DATA_HOME="${XDG_DATA_HOME:-${HOME}/.local/share}"
|
||||
BIN_HOME="${HOME}/.local/bin"
|
||||
APPLICATION_HOME="${DATA_HOME}/applications"
|
||||
@@ -133,10 +139,12 @@ RELEASE_ROOT="${RELEASES_ROOT}/${VERSION}"
|
||||
if [[ ! -d "${SOURCE_ROOT}/ptlinux" \
|
||||
|| ! -f "${SOURCE_ROOT}/ptdiag/assbin.py" \
|
||||
|| ! -f "${SOURCE_ROOT}/ptdiag/__init__.py" \
|
||||
|| ! -f "${SOURCE_ROOT}/ptdiag/logs.py" \
|
||||
|| ! -f "${SOURCE_ROOT}/ptlinux/desktop_entry.py" \
|
||||
|| ! -f "${SOURCE_ROOT}/ptlinux/launcher_wrapper.py" \
|
||||
|| ! -f "${SOURCE_ROOT}/ptlinux/self_update.py" \
|
||||
|| ! -f "${SOURCE_ROOT}/ptlinux/launcher-update-public.pem" \
|
||||
|| ! -f "${SOURCE_ROOT}/ptlinux/native/pt-identity-probe.exe" \
|
||||
|| ! -f "${SOURCE_ROOT}/packaging/eu.pristontale.PTLinux.desktop" \
|
||||
|| ! -f "${SOURCE_ROOT}/packaging/eu.pristontale.PTLinux.png" \
|
||||
|| ! -f "${SOURCE_ROOT}/packaging/pristontale-eu" \
|
||||
@@ -190,17 +198,32 @@ then
|
||||
exit 1
|
||||
fi
|
||||
install -d -m 0755 "$STAGING_RELEASE"
|
||||
install -d -m 0755 "${STAGING_RELEASE}/ptlinux" "${STAGING_RELEASE}/ptdiag"
|
||||
install -m 0644 "${SOURCE_ROOT}/ptlinux/"*.py "${STAGING_RELEASE}/ptlinux/"
|
||||
install -d -m 0755 \
|
||||
"${STAGING_RELEASE}/ptlinux" \
|
||||
"${STAGING_RELEASE}/ptlinux/native" \
|
||||
"${STAGING_RELEASE}/ptdiag"
|
||||
for SOURCE_MODULE in "${SOURCE_ROOT}/ptlinux/"*.py
|
||||
do
|
||||
case "${SOURCE_MODULE##*/}" in
|
||||
desktop_entry.py|launcher_wrapper.py)
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
install -m 0644 "$SOURCE_MODULE" "${STAGING_RELEASE}/ptlinux/"
|
||||
done
|
||||
install -m 0644 \
|
||||
"${SOURCE_ROOT}/ptlinux/launcher-update-public.pem" \
|
||||
"${STAGING_RELEASE}/ptlinux/launcher-update-public.pem"
|
||||
install -m 0644 \
|
||||
"${SOURCE_ROOT}/ptlinux/native/pt-identity-probe.exe" \
|
||||
"${STAGING_RELEASE}/ptlinux/native/pt-identity-probe.exe"
|
||||
install -m 0644 "${SOURCE_ROOT}/ptdiag/__init__.py" "${STAGING_RELEASE}/ptdiag/__init__.py"
|
||||
install -m 0644 "${SOURCE_ROOT}/ptdiag/assbin.py" "${STAGING_RELEASE}/ptdiag/assbin.py"
|
||||
install -m 0644 "${SOURCE_ROOT}/ptdiag/logs.py" "${STAGING_RELEASE}/ptdiag/logs.py"
|
||||
printf '{\n "schema": 1,\n "version": "%s"\n}\n' "$VERSION" > "${STAGING_RELEASE}/release.json"
|
||||
chmod 0644 "${STAGING_RELEASE}/release.json"
|
||||
|
||||
if ! "$PYTHON_BIN" -I -c \
|
||||
if ! "$PYTHON_BIN" -I -B -c \
|
||||
'import sys; sys.path.insert(0, sys.argv[1]); import ptlinux, ptlinux.cli, ptlinux.self_update; raise SystemExit(0 if ptlinux.__version__ == sys.argv[2] else 1)' \
|
||||
"$STAGING_RELEASE" "$VERSION"
|
||||
then
|
||||
@@ -277,7 +300,7 @@ CURRENT_LINK="${APP_ROOT}/.current.$$.tmp"
|
||||
ln -s -- "releases/${VERSION}" "$CURRENT_LINK"
|
||||
mv -Tf -- "$CURRENT_LINK" "${APP_ROOT}/current"
|
||||
|
||||
"$PYTHON_BIN" "${RELEASE_ROOT}/ptlinux/launcher_wrapper.py" \
|
||||
"$PYTHON_BIN" "${SOURCE_ROOT}/ptlinux/launcher_wrapper.py" \
|
||||
"${SOURCE_ROOT}/packaging/pristontale-eu" \
|
||||
"$APP_ROOT" \
|
||||
"$INSTALL_DIRECTORY" \
|
||||
@@ -285,7 +308,7 @@ mv -Tf -- "$CURRENT_LINK" "${APP_ROOT}/current"
|
||||
install -m 0644 \
|
||||
"${SOURCE_ROOT}/packaging/eu.pristontale.PTLinux.png" \
|
||||
"$ICON_PATH"
|
||||
"$PYTHON_BIN" "${RELEASE_ROOT}/ptlinux/desktop_entry.py" \
|
||||
"$PYTHON_BIN" "${SOURCE_ROOT}/ptlinux/desktop_entry.py" \
|
||||
"${SOURCE_ROOT}/packaging/eu.pristontale.PTLinux.desktop" \
|
||||
"${BIN_HOME}/pristontale-eu" \
|
||||
"$ICON_PATH" \
|
||||
@@ -301,8 +324,44 @@ then
|
||||
update-desktop-database "$APPLICATION_HOME" >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
printf '%s\n' "Application installed."
|
||||
printf '%s\n' "Launcher files installed."
|
||||
DOCTOR_READY=1
|
||||
if (( RUN_DOCTOR == 1 ))
|
||||
then
|
||||
"${BIN_HOME}/pristontale-eu" doctor || true
|
||||
if ! "${BIN_HOME}/pristontale-eu" doctor
|
||||
then
|
||||
DOCTOR_READY=0
|
||||
fi
|
||||
else
|
||||
printf '%s\n' "Host readiness check skipped."
|
||||
fi
|
||||
|
||||
printf '%s\n' "Start PristonTale EU with:"
|
||||
printf ' %q gui\n' "${BIN_HOME}/pristontale-eu"
|
||||
case ":${PATH:-}:" in
|
||||
*":${BIN_HOME}:"*)
|
||||
;;
|
||||
*)
|
||||
printf '%s\n' \
|
||||
"Note: ${BIN_HOME} is not in PATH for this shell." \
|
||||
"The application-menu shortcut already uses the full path."
|
||||
if [[ "${SHELL##*/}" == "fish" ]]
|
||||
then
|
||||
printf '%s\n' "Enable the short command in Fish with:"
|
||||
printf ' fish_add_path %q\n' "$BIN_HOME"
|
||||
else
|
||||
printf '%s\n' "Enable the short command in this Bash/Zsh session with:"
|
||||
printf ' export PATH=%q:"$PATH"\n' "$BIN_HOME"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
if (( DOCTOR_READY == 0 ))
|
||||
then
|
||||
printf '%s\n' \
|
||||
"Launcher files were installed, but this host is not ready to run the game." \
|
||||
"Resolve the diagnostics above, then run the launcher command again." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf '%s\n' "Installation ready."
|
||||
|
||||
@@ -8,3 +8,4 @@ Terminal=false
|
||||
Categories=Game;RolePlaying;
|
||||
Keywords=PristonTale;MMORPG;Wine;Proton;
|
||||
StartupNotify=true
|
||||
StartupWMClass=PristonTaleEU
|
||||
|
||||
@@ -121,6 +121,9 @@ ptlinux_resolve_dependency_packages()
|
||||
arch:openssl)
|
||||
candidates=(openssl)
|
||||
;;
|
||||
arch:umu)
|
||||
candidates=(umu-launcher)
|
||||
;;
|
||||
suse:python)
|
||||
candidates=(python3)
|
||||
;;
|
||||
@@ -210,6 +213,7 @@ ptlinux_has_wine_stack()
|
||||
ptlinux_detect_missing_dependencies()
|
||||
{
|
||||
local python_bin="${PYTHON_BIN:-python3}"
|
||||
local family="${1:-}"
|
||||
PTLINUX_MISSING_DEPENDENCIES=()
|
||||
|
||||
if ! command -v "$python_bin" >/dev/null 2>&1
|
||||
@@ -227,10 +231,6 @@ ptlinux_detect_missing_dependencies()
|
||||
then
|
||||
PTLINUX_MISSING_DEPENDENCIES+=(winetricks)
|
||||
fi
|
||||
if ! command -v gamescope >/dev/null 2>&1
|
||||
then
|
||||
PTLINUX_MISSING_DEPENDENCIES+=(gamescope)
|
||||
fi
|
||||
if ! command -v cabextract >/dev/null 2>&1
|
||||
then
|
||||
PTLINUX_MISSING_DEPENDENCIES+=(cabextract)
|
||||
@@ -239,6 +239,10 @@ ptlinux_detect_missing_dependencies()
|
||||
then
|
||||
PTLINUX_MISSING_DEPENDENCIES+=(openssl)
|
||||
fi
|
||||
if [[ "$family" == "arch" ]] && ! command -v umu-run >/dev/null 2>&1
|
||||
then
|
||||
PTLINUX_MISSING_DEPENDENCIES+=(umu)
|
||||
fi
|
||||
}
|
||||
|
||||
ptlinux_select_privilege_command()
|
||||
@@ -344,15 +348,14 @@ ptlinux_install_host_dependencies()
|
||||
local package_line
|
||||
local -a packages=()
|
||||
|
||||
ptlinux_detect_missing_dependencies
|
||||
ptlinux_read_distribution
|
||||
family="$(ptlinux_dependency_family "$PTLINUX_DISTRIBUTION_ID" "$PTLINUX_DISTRIBUTION_LIKE")"
|
||||
ptlinux_detect_missing_dependencies "$family"
|
||||
if (( ${#PTLINUX_MISSING_DEPENDENCIES[@]} == 0 ))
|
||||
then
|
||||
printf '%s\n' "All required host dependencies are already available."
|
||||
return 0
|
||||
fi
|
||||
|
||||
ptlinux_read_distribution
|
||||
family="$(ptlinux_dependency_family "$PTLINUX_DISTRIBUTION_ID" "$PTLINUX_DISTRIBUTION_LIKE")"
|
||||
if [[ "$family" == "unknown" ]]
|
||||
then
|
||||
printf 'Unsupported distribution: %s. Re-run with --skip-deps after installing: %s\n' \
|
||||
@@ -374,7 +377,7 @@ ptlinux_install_host_dependencies()
|
||||
printf '%s\n' "Installing missing host dependencies (authentication may be requested)."
|
||||
ptlinux_install_resolved_packages "$family" "${packages[@]}"
|
||||
hash -r
|
||||
ptlinux_detect_missing_dependencies
|
||||
ptlinux_detect_missing_dependencies "$family"
|
||||
if (( ${#PTLINUX_MISSING_DEPENDENCIES[@]} != 0 ))
|
||||
then
|
||||
printf 'Dependencies remain unavailable after package installation: %s\n' \
|
||||
|
||||
@@ -16,7 +16,7 @@ do
|
||||
then
|
||||
continue
|
||||
fi
|
||||
if "$python_bin" -I -c \
|
||||
if "$python_bin" -I -B -c \
|
||||
'import sys; sys.path.insert(0, sys.argv[1]); import ptlinux, ptlinux.cli' \
|
||||
"${candidate}" >/dev/null 2>&1
|
||||
then
|
||||
@@ -31,11 +31,11 @@ then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -n "${PYTHONPATH:-}" ]
|
||||
then
|
||||
export PYTHONPATH="${release_root}:${PYTHONPATH}"
|
||||
else
|
||||
export PYTHONPATH="${release_root}"
|
||||
fi
|
||||
# Never let an unpacked older installer in the caller's current directory
|
||||
# shadow the selected installed release. Children started by the dashboard
|
||||
# inherit the same safe-path policy, package root, and no-bytecode policy.
|
||||
export PYTHONSAFEPATH=1
|
||||
export PYTHONDONTWRITEBYTECODE=1
|
||||
export PYTHONPATH="${release_root}"
|
||||
|
||||
exec "$python_bin" -m ptlinux "$@"
|
||||
exec "$python_bin" -B -P -m ptlinux "$@"
|
||||
|
||||
@@ -23,6 +23,11 @@ _FAILED_IMPORT_RE = re.compile(
|
||||
r"Failed to import scene:\s*(?P<scene>.*)$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_MODULE_CODE_SYNC_RE = re.compile(
|
||||
r"Module Code Sync Error\s*\(\[(?P<left>-?\d+)\]\s*!=\s*"
|
||||
r"\[(?P<right>-?\d+)\]\)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_TIMESTAMP_RE = re.compile(
|
||||
r"^\s*(?:\[)?(?P<timestamp>"
|
||||
r"\d{4}-\d{2}-\d{2}[T ][0-9:.+Z-]+|\d{2}:\d{2}:\d{2}(?:\.\d+)?)"
|
||||
@@ -72,6 +77,11 @@ class NativeLogAnalysis:
|
||||
import_scene_threw: bool = False
|
||||
stage_without_new_stage: bool = False
|
||||
tex_reclaim_count: int = 0
|
||||
module_code_sync_error: bool = False
|
||||
module_code_sync_left: int | None = None
|
||||
module_code_sync_right: int | None = None
|
||||
module_code_sync_delta: int | None = None
|
||||
banned_code_1: bool = False
|
||||
|
||||
def latest_scene(self, name: str) -> SceneImport | None:
|
||||
wanted = _scene_key(name)
|
||||
@@ -95,6 +105,11 @@ class NativeLogAnalysis:
|
||||
"failed_files": [event.to_dict() for event in self.failed_files],
|
||||
"stage_without_new_stage": self.stage_without_new_stage,
|
||||
"tex_reclaim_count": self.tex_reclaim_count,
|
||||
"module_code_sync_error": self.module_code_sync_error,
|
||||
"module_code_sync_left": self.module_code_sync_left,
|
||||
"module_code_sync_right": self.module_code_sync_right,
|
||||
"module_code_sync_delta": self.module_code_sync_delta,
|
||||
"banned_code_1": self.banned_code_1,
|
||||
}
|
||||
for output_key, scene_name in (
|
||||
("whispering_vale", "WhisperingVale"),
|
||||
@@ -243,6 +258,27 @@ def parse_native_log_lines(lines: Iterable[str]) -> NativeLogAnalysis:
|
||||
analysis.import_scene_threw = True
|
||||
analysis.events.append(LogEvent("import_scene_threw", line_number, raw, timestamp))
|
||||
|
||||
module_sync = _MODULE_CODE_SYNC_RE.search(raw)
|
||||
if module_sync is not None:
|
||||
left = int(module_sync.group("left"))
|
||||
right = int(module_sync.group("right"))
|
||||
analysis.module_code_sync_error = True
|
||||
analysis.module_code_sync_left = left
|
||||
analysis.module_code_sync_right = right
|
||||
analysis.module_code_sync_delta = abs(left - right)
|
||||
analysis.events.append(
|
||||
LogEvent(
|
||||
"module_code_sync_error",
|
||||
line_number,
|
||||
raw,
|
||||
timestamp,
|
||||
f"{left} != {right}",
|
||||
)
|
||||
)
|
||||
if "banned code 1" in raw.casefold():
|
||||
analysis.banned_code_1 = True
|
||||
analysis.events.append(LogEvent("banned_code_1", line_number, raw, timestamp))
|
||||
|
||||
failed_import = _FAILED_IMPORT_RE.search(raw)
|
||||
if failed_import is not None:
|
||||
value = failed_import.group("scene").strip()
|
||||
|
||||
@@ -4,4 +4,4 @@ from .paths import AppPaths
|
||||
|
||||
__all__ = ["AppPaths", "__version__"]
|
||||
|
||||
__version__ = "0.5.2"
|
||||
__version__ = "0.8.1"
|
||||
|
||||
332
ptlinux/changelog.py
Normal file
332
ptlinux/changelog.py
Normal file
@@ -0,0 +1,332 @@
|
||||
"""Official patch changelog support for the native Linux dashboard."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import UTC, datetime
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
from typing import Callable, Protocol
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
|
||||
PATCHES_ENDPOINT = "https://pristontale.eu/api/changelog/patches"
|
||||
PATCH_NEWS_URL = "https://pristontale.eu/news#"
|
||||
MAX_PATCHES = 10
|
||||
MAX_PATCH_BYTES = 2 * 1024 * 1024
|
||||
MAX_PATCH_SECTIONS = 32
|
||||
MAX_PATCH_CHANGES = 200
|
||||
_MAX_PATCH_CANDIDATES = 100
|
||||
_SLUG_PATTERN = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)*")
|
||||
|
||||
|
||||
class ChangelogError(RuntimeError):
|
||||
"""Raised when the official patch feed cannot be parsed safely."""
|
||||
|
||||
|
||||
class ChangelogResponse(Protocol):
|
||||
"""The minimal response interface used by the changelog client."""
|
||||
|
||||
def read(self, size: int = -1) -> bytes: ...
|
||||
|
||||
def __enter__(self) -> ChangelogResponse: ...
|
||||
|
||||
def __exit__(
|
||||
self,
|
||||
exception_type: type[BaseException] | None,
|
||||
exception: BaseException | None,
|
||||
traceback: object | None,
|
||||
) -> bool | None: ...
|
||||
|
||||
|
||||
ChangelogOpener = Callable[..., ChangelogResponse]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PatchChange:
|
||||
text: str
|
||||
type_key: str
|
||||
|
||||
@property
|
||||
def tag(self) -> str:
|
||||
return patch_change_tag(self.type_key)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PatchSection:
|
||||
title: str
|
||||
items: tuple[PatchChange, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PatchNote:
|
||||
id: int
|
||||
slug: str
|
||||
title: str
|
||||
version: str | None
|
||||
tags: tuple[str, ...]
|
||||
summary: str
|
||||
sections: tuple[PatchSection, ...]
|
||||
published_at: str | None
|
||||
|
||||
@property
|
||||
def url(self) -> str:
|
||||
return f"{PATCH_NEWS_URL}{self.slug}"
|
||||
|
||||
|
||||
def patch_change_tag(type_key: str) -> str:
|
||||
"""Turn API keys such as ``new-feature`` into compact UI tags."""
|
||||
|
||||
label = " ".join(type_key.replace("_", "-").split("-"))
|
||||
return label.upper() if label else "CHANGE"
|
||||
|
||||
|
||||
def fetch_patch_notes(
|
||||
cache_path: Path,
|
||||
*,
|
||||
timeout_seconds: float = 15.0,
|
||||
opener: ChangelogOpener = urlopen,
|
||||
) -> tuple[PatchNote, ...]:
|
||||
"""Fetch the ten latest patches, falling back to the last valid cache."""
|
||||
|
||||
request = Request(
|
||||
PATCHES_ENDPOINT,
|
||||
headers={"User-Agent": "PT-Linux/0.6 (+https://pristontale.eu/)"},
|
||||
method="GET",
|
||||
)
|
||||
try:
|
||||
response = opener(request, timeout=timeout_seconds)
|
||||
with response:
|
||||
payload = response.read(MAX_PATCH_BYTES + 1)
|
||||
if len(payload) > MAX_PATCH_BYTES:
|
||||
raise ChangelogError("official patch response exceeds the 2 MiB limit")
|
||||
patches = _parse_patch_notes(payload)
|
||||
_write_patch_cache(cache_path, patches)
|
||||
return patches
|
||||
except (OSError, ChangelogError, UnicodeDecodeError, json.JSONDecodeError):
|
||||
return read_cached_patch_notes(cache_path)
|
||||
|
||||
|
||||
def read_cached_patch_notes(cache_path: Path) -> tuple[PatchNote, ...]:
|
||||
"""Read the last valid patch feed without performing network I/O."""
|
||||
|
||||
try:
|
||||
value = json.loads(cache_path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeError, json.JSONDecodeError):
|
||||
return ()
|
||||
if not isinstance(value, list):
|
||||
return ()
|
||||
patches: list[PatchNote] = []
|
||||
for item in value[:MAX_PATCHES]:
|
||||
patch = _patch_note_from_cache(item)
|
||||
if patch is not None:
|
||||
patches.append(patch)
|
||||
return tuple(patches)
|
||||
|
||||
|
||||
def _parse_patch_notes(payload: bytes) -> tuple[PatchNote, ...]:
|
||||
value = json.loads(payload.decode("utf-8-sig"))
|
||||
if isinstance(value, dict):
|
||||
value = value.get("data")
|
||||
if not isinstance(value, list):
|
||||
raise ChangelogError("official patch response is not a patch list")
|
||||
parsed: list[tuple[float, int, PatchNote]] = []
|
||||
for item in value[:_MAX_PATCH_CANDIDATES]:
|
||||
result = _parse_patch_note(item)
|
||||
if result is not None:
|
||||
timestamp, patch = result
|
||||
parsed.append((timestamp, patch.id, patch))
|
||||
parsed.sort(key=lambda entry: (entry[0], entry[1]), reverse=True)
|
||||
patches = tuple(entry[2] for entry in parsed[:MAX_PATCHES])
|
||||
if not patches:
|
||||
raise ChangelogError("official patch response contains no usable patches")
|
||||
return patches
|
||||
|
||||
|
||||
def _parse_patch_note(value: object) -> tuple[float, PatchNote] | None:
|
||||
if not isinstance(value, dict):
|
||||
return None
|
||||
patch_id = value.get("id")
|
||||
slug = _clean_text(value.get("slug"), 180)
|
||||
title = _clean_text(value.get("title"), 200)
|
||||
summary = _clean_text(value.get("summary"), 3000, allow_empty=True)
|
||||
if (
|
||||
isinstance(patch_id, bool)
|
||||
or not isinstance(patch_id, int)
|
||||
or slug is None
|
||||
or _SLUG_PATTERN.fullmatch(slug) is None
|
||||
or title is None
|
||||
or summary is None
|
||||
):
|
||||
return None
|
||||
version = _clean_text(value.get("version"), 64, allow_none=True)
|
||||
if value.get("version") is not None and version is None:
|
||||
return None
|
||||
timestamp = _timestamp(value.get("date"))
|
||||
patch = PatchNote(
|
||||
id=patch_id,
|
||||
slug=slug,
|
||||
title=title,
|
||||
version=version,
|
||||
tags=_parse_tags(value.get("tags")),
|
||||
summary=summary,
|
||||
sections=_parse_sections(value.get("changes")),
|
||||
published_at=_format_timestamp(timestamp),
|
||||
)
|
||||
return (timestamp if timestamp is not None else -1.0, patch)
|
||||
|
||||
|
||||
def _parse_tags(value: object) -> tuple[str, ...]:
|
||||
if not isinstance(value, list):
|
||||
return ()
|
||||
tags: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for item in value[:16]:
|
||||
tag = _clean_text(item, 48)
|
||||
if tag is None or tag.casefold() in seen:
|
||||
continue
|
||||
seen.add(tag.casefold())
|
||||
tags.append(tag)
|
||||
return tuple(tags)
|
||||
|
||||
|
||||
def _parse_sections(value: object) -> tuple[PatchSection, ...]:
|
||||
if not isinstance(value, dict):
|
||||
return ()
|
||||
sections_value = value.get("sections")
|
||||
if not isinstance(sections_value, list):
|
||||
return ()
|
||||
sections: list[PatchSection] = []
|
||||
changes_seen = 0
|
||||
for section_value in sections_value[:MAX_PATCH_SECTIONS]:
|
||||
if not isinstance(section_value, dict):
|
||||
continue
|
||||
title = _clean_text(section_value.get("title"), 100) or "Changes"
|
||||
items_value = section_value.get("items")
|
||||
if not isinstance(items_value, list):
|
||||
continue
|
||||
items: list[PatchChange] = []
|
||||
for item in items_value:
|
||||
if changes_seen >= MAX_PATCH_CHANGES:
|
||||
break
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
text = _clean_text(item.get("text"), 4000)
|
||||
type_key = _clean_text(item.get("type_key"), 64)
|
||||
if text is None:
|
||||
continue
|
||||
items.append(
|
||||
PatchChange(
|
||||
text=text,
|
||||
type_key=type_key or _type_key_from_title(title),
|
||||
)
|
||||
)
|
||||
changes_seen += 1
|
||||
if items:
|
||||
sections.append(PatchSection(title=title, items=tuple(items)))
|
||||
if changes_seen >= MAX_PATCH_CHANGES:
|
||||
break
|
||||
return tuple(sections)
|
||||
|
||||
|
||||
def _patch_note_from_cache(value: object) -> PatchNote | None:
|
||||
if not isinstance(value, dict):
|
||||
return None
|
||||
patch_id = value.get("id")
|
||||
slug = _clean_text(value.get("slug"), 180)
|
||||
title = _clean_text(value.get("title"), 200)
|
||||
summary = _clean_text(value.get("summary"), 3000, allow_empty=True)
|
||||
version = _clean_text(value.get("version"), 64, allow_none=True)
|
||||
published_at = _clean_text(value.get("published_at"), 64, allow_none=True)
|
||||
if (
|
||||
isinstance(patch_id, bool)
|
||||
or not isinstance(patch_id, int)
|
||||
or slug is None
|
||||
or _SLUG_PATTERN.fullmatch(slug) is None
|
||||
or title is None
|
||||
or summary is None
|
||||
):
|
||||
return None
|
||||
sections_value = value.get("sections")
|
||||
sections: list[PatchSection] = []
|
||||
if isinstance(sections_value, list):
|
||||
for section_value in sections_value[:MAX_PATCH_SECTIONS]:
|
||||
if not isinstance(section_value, dict):
|
||||
continue
|
||||
section_title = _clean_text(section_value.get("title"), 100)
|
||||
items_value = section_value.get("items")
|
||||
if section_title is None or not isinstance(items_value, list):
|
||||
continue
|
||||
items: list[PatchChange] = []
|
||||
for item in items_value:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
text = _clean_text(item.get("text"), 4000)
|
||||
type_key = _clean_text(item.get("type_key"), 64)
|
||||
if text is not None and type_key is not None:
|
||||
items.append(PatchChange(text=text, type_key=type_key))
|
||||
if items:
|
||||
sections.append(PatchSection(section_title, tuple(items)))
|
||||
return PatchNote(
|
||||
id=patch_id,
|
||||
slug=slug,
|
||||
title=title,
|
||||
version=version,
|
||||
tags=_parse_tags(value.get("tags")),
|
||||
summary=summary,
|
||||
sections=tuple(sections),
|
||||
published_at=published_at,
|
||||
)
|
||||
|
||||
|
||||
def _clean_text(
|
||||
value: object,
|
||||
maximum_length: int,
|
||||
*,
|
||||
allow_empty: bool = False,
|
||||
allow_none: bool = False,
|
||||
) -> str | None:
|
||||
if value is None and allow_none:
|
||||
return None
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
text = " ".join(value.split())
|
||||
if len(text) > maximum_length or (not text and not allow_empty):
|
||||
return None
|
||||
return text
|
||||
|
||||
|
||||
def _timestamp(value: object) -> float | None:
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
return None
|
||||
timestamp = float(value)
|
||||
return timestamp if math.isfinite(timestamp) else None
|
||||
|
||||
|
||||
def _format_timestamp(value: float | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromtimestamp(value, UTC).astimezone().strftime("%d %b %Y")
|
||||
except (OverflowError, OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _type_key_from_title(title: str) -> str:
|
||||
return re.sub(r"[^a-z0-9]+", "-", title.casefold()).strip("-") or "change"
|
||||
|
||||
|
||||
def _write_patch_cache(path: Path, patches: tuple[PatchNote, ...]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
||||
try:
|
||||
temporary.write_text(
|
||||
json.dumps([asdict(patch) for patch in patches], indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
temporary.replace(path)
|
||||
except OSError:
|
||||
temporary.unlink(missing_ok=True)
|
||||
493
ptlinux/cli.py
493
ptlinux/cli.py
@@ -9,14 +9,28 @@ from datetime import datetime
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from typing import Sequence
|
||||
import time
|
||||
from typing import Callable, Sequence
|
||||
|
||||
from ptdiag.logs import (
|
||||
LogFileState,
|
||||
newest_current_native_log,
|
||||
parse_current_run_log,
|
||||
snapshot_native_logs,
|
||||
)
|
||||
|
||||
from . import __version__
|
||||
from .command import CommandError, CommandRunner
|
||||
from .depot import SyncProgress, sync_managed_game
|
||||
from .depot import (
|
||||
SyncProgress,
|
||||
read_cached_depot_launch_catalog,
|
||||
select_depot_start_option,
|
||||
sync_managed_game,
|
||||
)
|
||||
from .doctor import inspect_host
|
||||
from .download import OFFICIAL_LAUNCHER_URL, fetch_official_launcher
|
||||
from .importer import GameImportResult, discover_existing_game, import_existing_game
|
||||
from .import_state import game_import_requires_verification, mark_game_import_verified
|
||||
from .launcher import (
|
||||
LauncherError,
|
||||
configure_launcher_prefix,
|
||||
@@ -24,21 +38,24 @@ from .launcher import (
|
||||
launch_official_updater,
|
||||
prepare_launcher_binary,
|
||||
)
|
||||
from .locking import launcher_operation_lock
|
||||
from .locking import LauncherPlayLease, launcher_operation_lock, launcher_play_lock
|
||||
from .login_diagnostics import inspect_login_environment
|
||||
from .overlay import cache_compatibility_binaries, cache_runtime_patch, prepare_runtime_view
|
||||
from .login_trace import DEFAULT_TRACE_TIMEOUT_SECONDS, LOGIN_TRACE_DISABLED_REASON
|
||||
from .overlay import cache_compatibility_binaries, prepare_runtime_view
|
||||
from .paths import AppPaths
|
||||
from .runtime import (
|
||||
game_runtime_ready,
|
||||
prepare_game_runtime,
|
||||
run_game,
|
||||
select_runtime,
|
||||
)
|
||||
from .settings import Settings, ensure_settings, load_settings, save_settings
|
||||
from .settings import Settings, ensure_settings, load_settings, reset_settings
|
||||
from .self_update import (
|
||||
check_for_launcher_update,
|
||||
install_launcher_update,
|
||||
rollback_launcher_update,
|
||||
update_check_to_dict,
|
||||
verify_launcher_update_channel,
|
||||
)
|
||||
|
||||
|
||||
@@ -67,6 +84,15 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
default=5.0,
|
||||
help="TCP connection timeout in seconds (default: 5)",
|
||||
)
|
||||
login_diagnostics.add_argument(
|
||||
"--runtime",
|
||||
choices=("configured", "auto", "proton7", "proton7-container", "umu"),
|
||||
default="configured",
|
||||
help=(
|
||||
"probe one runtime without changing settings or starting the game "
|
||||
"(default: configured)"
|
||||
),
|
||||
)
|
||||
|
||||
fetch = subparsers.add_parser("fetch", help="fetch and safely prepare the latest official launcher")
|
||||
fetch.add_argument("--dry-run", action="store_true")
|
||||
@@ -122,27 +148,58 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
help="cache one exact verified Game.exe/game.dll compatibility pair",
|
||||
)
|
||||
compatibility.add_argument("source", type=Path)
|
||||
runtime_patch = subparsers.add_parser(
|
||||
"cache-runtime-patch",
|
||||
help="cache the exact verified V21 texture-reclaim game.dll",
|
||||
)
|
||||
runtime_patch.add_argument("source", type=Path)
|
||||
|
||||
prepare = subparsers.add_parser("prepare", help="build the read-only runtime view and scene fix")
|
||||
prepare.add_argument("--game-dir", type=Path)
|
||||
|
||||
play = subparsers.add_parser("play", help="launch the game in the clean Gamescope box")
|
||||
play = subparsers.add_parser("play", help="launch the game in the managed Proton window")
|
||||
play.add_argument("--game-dir", type=Path)
|
||||
play.add_argument("--no-box", action="store_true", help="launch without Gamescope")
|
||||
play.add_argument(
|
||||
"--realm",
|
||||
help="launch an available realm from the last official depot manifest",
|
||||
)
|
||||
play.add_argument("--no-box", action="store_true", help="launch without X11 window styling")
|
||||
play.add_argument(
|
||||
"--publisher-dll",
|
||||
"--no-world-fix",
|
||||
action="store_true",
|
||||
dest="publisher_dll",
|
||||
help=(
|
||||
"compatibility option retained for older scripts; publisher code is "
|
||||
"always used without the retired world fix"
|
||||
),
|
||||
)
|
||||
play.add_argument("--dry-run", action="store_true")
|
||||
play.add_argument("--skip-runtime-setup", action="store_true")
|
||||
login_trace = subparsers.add_parser(
|
||||
"login-trace",
|
||||
help="retired unsafe authentication trace (always refuses to launch)",
|
||||
)
|
||||
login_trace.add_argument("--game-dir", type=Path)
|
||||
login_trace.add_argument("--no-box", action="store_true", help="launch without X11 window styling")
|
||||
login_trace.add_argument("--skip-runtime-setup", action="store_true")
|
||||
login_trace.add_argument(
|
||||
"--timeout",
|
||||
type=float,
|
||||
default=DEFAULT_TRACE_TIMEOUT_SECONDS,
|
||||
help="maximum game runtime in seconds (default: 600)",
|
||||
)
|
||||
|
||||
settings = subparsers.add_parser("settings", help="show or initialize settings")
|
||||
settings.add_argument("--init", action="store_true", help="write conservative defaults")
|
||||
settings = subparsers.add_parser("settings", help="show, initialize, or reset settings")
|
||||
settings_action = settings.add_mutually_exclusive_group()
|
||||
settings_action.add_argument(
|
||||
"--init",
|
||||
action="store_true",
|
||||
help="create missing settings and migrate legacy files without resetting choices",
|
||||
)
|
||||
settings_action.add_argument(
|
||||
"--reset",
|
||||
action="store_true",
|
||||
help="explicitly replace all launcher settings with defaults",
|
||||
)
|
||||
|
||||
launcher_update = subparsers.add_parser(
|
||||
"launcher-update",
|
||||
help="check, install, or roll back the native Linux launcher",
|
||||
help="check, verify, install, or roll back the native Linux launcher",
|
||||
)
|
||||
launcher_action = launcher_update.add_mutually_exclusive_group()
|
||||
launcher_action.add_argument(
|
||||
@@ -150,6 +207,11 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
action="store_true",
|
||||
help="authenticate the update manifest without installing it",
|
||||
)
|
||||
launcher_action.add_argument(
|
||||
"--verify",
|
||||
action="store_true",
|
||||
help="download and fully validate the published release without installing it",
|
||||
)
|
||||
launcher_action.add_argument(
|
||||
"--rollback",
|
||||
action="store_true",
|
||||
@@ -181,7 +243,12 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
if command == "doctor":
|
||||
return _doctor(arguments.json)
|
||||
if command == "login-diagnostics":
|
||||
return _login_diagnostics(paths, arguments.timeout, arguments.json)
|
||||
return _login_diagnostics(
|
||||
paths,
|
||||
arguments.timeout,
|
||||
arguments.runtime,
|
||||
arguments.json,
|
||||
)
|
||||
if command == "fetch":
|
||||
return _fetch(paths, arguments.dry_run, arguments.json)
|
||||
if command == "setup":
|
||||
@@ -229,10 +296,6 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
result = cache_compatibility_binaries(paths, arguments.source)
|
||||
_emit({"ok": True, "compatibility": asdict(result)}, arguments.json)
|
||||
return 0
|
||||
if command == "cache-runtime-patch":
|
||||
result = cache_runtime_patch(paths, arguments.source)
|
||||
_emit({"ok": True, "runtime_patch": asdict(result)}, arguments.json)
|
||||
return 0
|
||||
if command == "prepare":
|
||||
return _prepare(paths, arguments.game_dir, arguments.json)
|
||||
if command == "play":
|
||||
@@ -243,19 +306,37 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
arguments.dry_run,
|
||||
arguments.skip_runtime_setup,
|
||||
arguments.json,
|
||||
realm=arguments.realm,
|
||||
)
|
||||
if command == "login-trace":
|
||||
return _login_trace(
|
||||
paths,
|
||||
arguments.game_dir,
|
||||
arguments.no_box,
|
||||
arguments.skip_runtime_setup,
|
||||
arguments.timeout,
|
||||
arguments.json,
|
||||
)
|
||||
if command == "settings":
|
||||
return _settings(paths, arguments.init, arguments.json)
|
||||
return _settings(paths, arguments.init, arguments.reset, arguments.json)
|
||||
if command == "launcher-update":
|
||||
return _launcher_update(
|
||||
paths,
|
||||
arguments.check,
|
||||
arguments.verify,
|
||||
arguments.rollback,
|
||||
arguments.manifest_url,
|
||||
arguments.public_key,
|
||||
arguments.json,
|
||||
)
|
||||
parser.error(f"unknown command: {command}")
|
||||
except KeyboardInterrupt:
|
||||
message = "Interrupted; PristonTale and its compatibility runtime were stopped."
|
||||
if arguments.json:
|
||||
_print_json({"ok": False, "error": message, "interrupted": True})
|
||||
else:
|
||||
print(f"\n{message}", file=sys.stderr)
|
||||
return 130
|
||||
except (ValueError, OSError, RuntimeError) as exc:
|
||||
if arguments.json:
|
||||
_print_json({"ok": False, "error": str(exc)})
|
||||
@@ -287,25 +368,55 @@ def _doctor(as_json: bool) -> int:
|
||||
return 0 if report.ok else 1
|
||||
|
||||
|
||||
def _login_diagnostics(paths: AppPaths, timeout_seconds: float, as_json: bool) -> int:
|
||||
def _login_diagnostics(
|
||||
paths: AppPaths,
|
||||
timeout_seconds: float,
|
||||
runtime_choice: str,
|
||||
as_json: bool,
|
||||
) -> int:
|
||||
settings = _load_or_default_settings(paths, persist=False)
|
||||
report = inspect_login_environment(
|
||||
paths,
|
||||
settings,
|
||||
timeout_seconds=timeout_seconds,
|
||||
)
|
||||
runner = _runner(paths, "login-diagnostics", False)
|
||||
with _operation_guard(paths, "login-diagnostics", False):
|
||||
report = inspect_login_environment(
|
||||
paths,
|
||||
settings,
|
||||
timeout_seconds=timeout_seconds,
|
||||
runner=runner,
|
||||
runtime_override=(
|
||||
None if runtime_choice == "configured" else runtime_choice
|
||||
),
|
||||
)
|
||||
if as_json:
|
||||
_print_json(report.to_dict())
|
||||
else:
|
||||
probe = report.login_server
|
||||
print(f"Launcher: {report.launcher_version} ({report.launcher_module})")
|
||||
print(f"Python: {report.python_executable}")
|
||||
print(f"Host: {report.distribution_id} ({report.kernel_release})")
|
||||
print(
|
||||
"Runtime test: "
|
||||
f"{report.runtime_requested} "
|
||||
f"(configured: {report.runtime_configured}; settings unchanged)"
|
||||
)
|
||||
status = "REACHABLE" if probe.reachable else "UNREACHABLE"
|
||||
latency = f", {probe.latency_ms:.1f} ms" if probe.latency_ms is not None else ""
|
||||
print(f"Login server: {status} ({probe.host}:{probe.port}{latency})")
|
||||
if probe.error:
|
||||
print(f"Network error: {probe.error}")
|
||||
print("Authentication: not tested; no credentials or game-protocol data were sent")
|
||||
print(f"Login responses: {report.login_response_ownership}")
|
||||
identity_status = (
|
||||
"ready"
|
||||
if report.client_identity_ready is True
|
||||
else "NOT READY"
|
||||
if report.client_identity_ready is False
|
||||
else "not probed"
|
||||
)
|
||||
print(f"Client identity: {identity_status}; raw identity values were not collected")
|
||||
print(f"Privacy: {report.client_identity_privacy}")
|
||||
if not report.login_trace_enabled:
|
||||
print("Login trace: retired (will not launch or instrument the game)")
|
||||
print(f"Publisher test: {report.publisher_comparison_command}")
|
||||
print(f"Game profile: {report.game_profile}")
|
||||
print(f"Game.exe: {report.game_exe}")
|
||||
if report.game_exe_sha256:
|
||||
@@ -313,10 +424,24 @@ def _login_diagnostics(paths: AppPaths, timeout_seconds: float, as_json: bool) -
|
||||
print(f"game.dll: {report.game_dll}")
|
||||
if report.game_dll_sha256:
|
||||
print(f"DLL SHA-256: {report.game_dll_sha256}")
|
||||
print(
|
||||
"Managed source: "
|
||||
+ ("matches runtime" if report.runtime_matches_managed_source else "DIFFERS")
|
||||
)
|
||||
if report.depot_build is not None:
|
||||
print(
|
||||
f"Depot: build {report.depot_build} "
|
||||
f"({report.depot_version or 'unknown version'})"
|
||||
)
|
||||
if report.runtime_kind:
|
||||
print(f"Runtime: {report.runtime_kind} ({report.runtime_path or 'unknown path'})")
|
||||
else:
|
||||
print(f"Runtime: unavailable ({report.runtime_error or 'selection failed'})")
|
||||
if report.proton_sync_effective is not None:
|
||||
print(
|
||||
"Proton sync: "
|
||||
f"{report.proton_sync_configured} -> {report.proton_sync_effective}"
|
||||
)
|
||||
prefix_status = "ready" if report.prefix_ready else "not ready"
|
||||
directx_status = "ready" if report.directx_ready else "not ready"
|
||||
print(f"Prefix: {prefix_status}; DirectX helpers {directx_status}")
|
||||
@@ -414,13 +539,22 @@ def _native_update(
|
||||
runner,
|
||||
announce=not as_json,
|
||||
)
|
||||
verify_import = game_import is not None or game_import_requires_verification(paths)
|
||||
_print_operation_status(as_json, 2, 3, "Synchronizing official game files…")
|
||||
result = sync_managed_game(
|
||||
paths,
|
||||
repair=repair,
|
||||
repair=repair or verify_import,
|
||||
quarantine_unmanaged=verify_import,
|
||||
dry_run=dry_run,
|
||||
progress=None if as_json else _print_sync_progress,
|
||||
)
|
||||
if verify_import and not dry_run:
|
||||
mark_game_import_verified(
|
||||
paths,
|
||||
build=result.build,
|
||||
files_quarantined=result.files_quarantined,
|
||||
quarantine_directory=result.quarantine_directory,
|
||||
)
|
||||
overlay = None
|
||||
if not dry_run and paths.source_game.is_dir():
|
||||
_print_operation_status(as_json, 3, 3, "Preparing the clean game runtime view…")
|
||||
@@ -469,6 +603,23 @@ def _wine_update(
|
||||
announce=not as_json,
|
||||
)
|
||||
updater = launch_official_updater(paths, tools, runner)
|
||||
native_verification = None
|
||||
verify_import = not dry_run and (
|
||||
game_import is not None or game_import_requires_verification(paths)
|
||||
)
|
||||
if verify_import:
|
||||
native_verification = sync_managed_game(
|
||||
paths,
|
||||
repair=True,
|
||||
quarantine_unmanaged=True,
|
||||
progress=None if as_json else _print_sync_progress,
|
||||
)
|
||||
mark_game_import_verified(
|
||||
paths,
|
||||
build=native_verification.build,
|
||||
files_quarantined=native_verification.files_quarantined,
|
||||
quarantine_directory=native_verification.quarantine_directory,
|
||||
)
|
||||
overlay = None
|
||||
if not dry_run and paths.source_game.is_dir():
|
||||
overlay = asdict(prepare_runtime_view(paths, paths.source_game))
|
||||
@@ -477,6 +628,9 @@ def _wine_update(
|
||||
"dry_run": dry_run,
|
||||
"game_import": asdict(game_import) if game_import else None,
|
||||
"updater": asdict(updater),
|
||||
"native_verification": (
|
||||
asdict(native_verification) if native_verification is not None else None
|
||||
),
|
||||
"runtime_view": overlay,
|
||||
}
|
||||
_emit(value, as_json)
|
||||
@@ -544,9 +698,15 @@ def _maybe_import_game(
|
||||
|
||||
|
||||
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)
|
||||
with _operation_guard(paths, "prepare", False):
|
||||
if game_dir is None and game_import_requires_verification(paths):
|
||||
raise LauncherError(
|
||||
"the imported game has not completed its full publisher verification; "
|
||||
"run pristontale-eu update"
|
||||
)
|
||||
settings = ensure_settings(paths)
|
||||
source = _source_game(paths, settings, game_dir)
|
||||
result = prepare_runtime_view(paths, source)
|
||||
_emit({"ok": True, "runtime_view": asdict(result)}, as_json)
|
||||
return 0
|
||||
|
||||
@@ -558,46 +718,250 @@ def _play(
|
||||
dry_run: bool,
|
||||
skip_runtime_setup: bool,
|
||||
as_json: bool,
|
||||
*,
|
||||
realm: str | None = None,
|
||||
) -> int:
|
||||
with _play_guard(paths, dry_run) as play_lease:
|
||||
return _play_locked(
|
||||
paths,
|
||||
game_dir,
|
||||
no_box,
|
||||
dry_run,
|
||||
skip_runtime_setup,
|
||||
as_json,
|
||||
realm=realm,
|
||||
reuse_prepared=not play_lease.preparation_allowed,
|
||||
before_game=play_lease.allow_shared_clients,
|
||||
)
|
||||
|
||||
|
||||
def _play_locked(
|
||||
paths: AppPaths,
|
||||
game_dir: Path | None,
|
||||
no_box: bool,
|
||||
dry_run: bool,
|
||||
skip_runtime_setup: bool,
|
||||
as_json: bool,
|
||||
*,
|
||||
realm: str | None = None,
|
||||
reuse_prepared: bool = False,
|
||||
before_game: Callable[[], None] | None = None,
|
||||
) -> int:
|
||||
if game_dir is None and game_import_requires_verification(paths):
|
||||
raise LauncherError(
|
||||
"the imported game has not completed its full publisher verification; "
|
||||
"run pristontale-eu update before playing"
|
||||
)
|
||||
settings = _load_or_default_settings(paths, persist=not dry_run)
|
||||
start_option = select_depot_start_option(
|
||||
read_cached_depot_launch_catalog(paths.depot_launch_catalog_cache),
|
||||
realm or settings.realm,
|
||||
)
|
||||
source = _source_game(paths, settings, game_dir)
|
||||
overlay = None
|
||||
_print_operation_status(as_json, 1, 4, "Preparing the managed game view…")
|
||||
if not dry_run:
|
||||
_print_operation_status(
|
||||
as_json,
|
||||
1,
|
||||
4,
|
||||
(
|
||||
"Reusing the active managed game view…"
|
||||
if reuse_prepared
|
||||
else "Preparing the managed game view…"
|
||||
),
|
||||
)
|
||||
if reuse_prepared:
|
||||
if game_dir is not None:
|
||||
raise LauncherError(
|
||||
"an additional client cannot replace the active managed game view; "
|
||||
"close every client before using --game-dir"
|
||||
)
|
||||
if not paths.runtime_view.is_dir():
|
||||
raise LauncherError(
|
||||
"another game client is active, but the prepared runtime view is missing"
|
||||
)
|
||||
elif not dry_run:
|
||||
overlay = prepare_runtime_view(paths, source)
|
||||
_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:
|
||||
if reuse_prepared:
|
||||
_print_operation_status(
|
||||
as_json,
|
||||
3,
|
||||
4,
|
||||
"Reusing the active Proton and DirectX runtime…",
|
||||
)
|
||||
if not game_runtime_ready(paths, selection):
|
||||
raise LauncherError(
|
||||
"another game client is active, but its prepared runtime no longer "
|
||||
"matches the current settings; close every client and launch again"
|
||||
)
|
||||
elif not skip_runtime_setup:
|
||||
_print_operation_status(as_json, 3, 4, "Preparing Proton and DirectX components…")
|
||||
setup_results = prepare_game_runtime(paths, selection, runner)
|
||||
else:
|
||||
_print_operation_status(as_json, 3, 4, "Using the existing Proton runtime…")
|
||||
_print_operation_status(as_json, 4, 4, "Starting the PristonTale game client…")
|
||||
outcome = run_game(
|
||||
paths,
|
||||
settings,
|
||||
selection,
|
||||
runner,
|
||||
use_gamebox=not no_box,
|
||||
native_log_directory = paths.game_save / "logs"
|
||||
native_log_baseline = snapshot_native_logs(
|
||||
native_log_directory,
|
||||
patterns=("log_*.txt",),
|
||||
)
|
||||
game_started_ns = time.time_ns()
|
||||
if before_game is not None:
|
||||
before_game()
|
||||
try:
|
||||
outcome = run_game(
|
||||
paths,
|
||||
settings,
|
||||
selection,
|
||||
runner,
|
||||
start_option=start_option,
|
||||
use_gamebox=not no_box,
|
||||
concurrent_prefix_launch=reuse_prepared,
|
||||
)
|
||||
except KeyboardInterrupt:
|
||||
current_game_log = _current_game_log(
|
||||
native_log_directory,
|
||||
native_log_baseline,
|
||||
game_started_ns,
|
||||
)
|
||||
if current_game_log is not None and not as_json:
|
||||
_print_interrupted_game_log(current_game_log)
|
||||
raise
|
||||
current_game_log = _current_game_log(
|
||||
native_log_directory,
|
||||
native_log_baseline,
|
||||
game_started_ns,
|
||||
)
|
||||
runtime_issues = _runtime_issue_summary(outcome.stderr)
|
||||
if (
|
||||
selection.kind == "proton7-container"
|
||||
and runtime_issues["host_futex_fatal_count"]
|
||||
):
|
||||
runtime_issues["recommended_one_variable_comparison"] = "proton7"
|
||||
_emit(
|
||||
{
|
||||
"ok": outcome.returncode == 0,
|
||||
"dry_run": dry_run,
|
||||
"runtime": asdict(selection),
|
||||
"realm": asdict(start_option),
|
||||
"runtime_view": asdict(overlay) if overlay else None,
|
||||
"setup_commands": [asdict(result) for result in setup_results],
|
||||
"game": asdict(outcome),
|
||||
"current_game_log": current_game_log,
|
||||
"runtime_issues": runtime_issues,
|
||||
},
|
||||
as_json,
|
||||
)
|
||||
return 0 if outcome.returncode == 0 else 1
|
||||
|
||||
|
||||
def _settings(paths: AppPaths, initialize: bool, as_json: bool) -> int:
|
||||
if initialize:
|
||||
save_settings(paths, Settings())
|
||||
settings = ensure_settings(paths) if initialize else load_settings(paths)
|
||||
def _current_game_log(
|
||||
directory: Path,
|
||||
baseline: dict[str, LogFileState],
|
||||
started_ns: int,
|
||||
) -> dict[str, object] | None:
|
||||
state = newest_current_native_log(
|
||||
directory,
|
||||
baseline,
|
||||
started_ns,
|
||||
patterns=("log_*.txt",),
|
||||
)
|
||||
if state is None:
|
||||
return None
|
||||
analysis = parse_current_run_log(state, baseline)
|
||||
return {
|
||||
"path": str(state.path),
|
||||
"modified_at": datetime.fromtimestamp(
|
||||
state.mtime_ns / 1_000_000_000
|
||||
).astimezone().isoformat(),
|
||||
"size": state.size,
|
||||
"load_texture_to_material_failed": analysis.load_texture_to_material_failed,
|
||||
"import_scene_threw": analysis.import_scene_threw,
|
||||
"failed_import_scenes": analysis.failed_import_scenes,
|
||||
"stage_without_new_stage": analysis.stage_without_new_stage,
|
||||
"module_code_sync_error": analysis.module_code_sync_error,
|
||||
"module_code_sync_left": analysis.module_code_sync_left,
|
||||
"module_code_sync_right": analysis.module_code_sync_right,
|
||||
"module_code_sync_delta": analysis.module_code_sync_delta,
|
||||
"banned_code_1": analysis.banned_code_1,
|
||||
"banned_code_1_in_native_log": analysis.banned_code_1,
|
||||
"banned_code_1_observation_scope": (
|
||||
"native-game-log-only; server-provided login-screen text is not "
|
||||
"written to this log"
|
||||
),
|
||||
"stage_without_new_stage_events": [
|
||||
event.to_dict()
|
||||
for event in analysis.events
|
||||
if event.kind == "stage_without_new_stage"
|
||||
],
|
||||
"scene_imports": {
|
||||
name: [scene.to_dict() for scene in imports]
|
||||
for name, imports in sorted(analysis.scenes.items())
|
||||
},
|
||||
"failed_file_count": len(analysis.failed_files),
|
||||
"failed_files": [event.to_dict() for event in analysis.failed_files[:64]],
|
||||
}
|
||||
|
||||
|
||||
def _runtime_issue_summary(stderr: str) -> dict[str, object]:
|
||||
folded = stderr.casefold()
|
||||
futex_message = "the futex facility returned an unexpected error code"
|
||||
wrong_elf_message = "wrong elf class: elfclass64"
|
||||
return {
|
||||
"host_futex_fatal_count": folded.count(futex_message),
|
||||
"gstreamer_wrong_elf_class_count": folded.count(wrong_elf_message),
|
||||
"gstreamer_stream_error": "winegstreamer error" in folded,
|
||||
"mesa_egl_initialization_failed": "egl: failed to create dri2 screen" in folded,
|
||||
"steam_api_app_id_missing": "steamapi_init() failed; no appid found" in folded,
|
||||
}
|
||||
|
||||
|
||||
def _print_interrupted_game_log(report: dict[str, object]) -> None:
|
||||
print(f"\nCurrent game log: {report['path']}", file=sys.stderr)
|
||||
if report["module_code_sync_error"]:
|
||||
print(
|
||||
"Detected a publisher module-integrity mismatch; the game was launched "
|
||||
"with publisher-pristine code in this launcher version.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
elif report["load_texture_to_material_failed"]:
|
||||
print(
|
||||
"Detected a texture/material failure while loading the game world.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
elif report["import_scene_threw"] or report["failed_import_scenes"]:
|
||||
print("Detected a game-world scene import failure.", file=sys.stderr)
|
||||
|
||||
|
||||
def _login_trace(
|
||||
paths: AppPaths,
|
||||
game_dir: Path | None,
|
||||
no_box: bool,
|
||||
skip_runtime_setup: bool,
|
||||
timeout_seconds: float,
|
||||
as_json: bool,
|
||||
) -> int:
|
||||
# Preserve the command name so old instructions fail safely before touching
|
||||
# the prefix, preparing the runtime view, or starting Game.exe.
|
||||
_ = paths, game_dir, no_box, skip_runtime_setup, timeout_seconds, as_json
|
||||
raise RuntimeError(LOGIN_TRACE_DISABLED_REASON)
|
||||
|
||||
|
||||
def _settings(
|
||||
paths: AppPaths,
|
||||
initialize: bool,
|
||||
reset: bool,
|
||||
as_json: bool,
|
||||
) -> int:
|
||||
if reset:
|
||||
settings = reset_settings(paths)
|
||||
elif initialize:
|
||||
settings = ensure_settings(paths)
|
||||
else:
|
||||
settings = load_settings(paths)
|
||||
_emit(
|
||||
{
|
||||
"ok": True,
|
||||
@@ -612,6 +976,7 @@ def _settings(paths: AppPaths, initialize: bool, as_json: bool) -> int:
|
||||
def _launcher_update(
|
||||
paths: AppPaths,
|
||||
check_only: bool,
|
||||
verify: bool,
|
||||
rollback: bool,
|
||||
manifest_url: str | None,
|
||||
public_key: Path | None,
|
||||
@@ -640,6 +1005,21 @@ def _launcher_update(
|
||||
as_json,
|
||||
)
|
||||
return 0
|
||||
if verify:
|
||||
_print_operation_status(as_json, 1, 2, "Authenticating the launcher release…")
|
||||
result = verify_launcher_update_channel(
|
||||
manifest_url=manifest_url,
|
||||
public_key_path=public_key,
|
||||
)
|
||||
_print_operation_status(as_json, 2, 2, "Published launcher release verified.")
|
||||
_emit(
|
||||
{
|
||||
"ok": True,
|
||||
"launcher_update_verification": asdict(result),
|
||||
},
|
||||
as_json,
|
||||
)
|
||||
return 0
|
||||
_print_operation_status(as_json, 1, 2, "Verifying the signed launcher release…")
|
||||
result = install_launcher_update(
|
||||
paths,
|
||||
@@ -659,14 +1039,18 @@ def _launcher_update(
|
||||
|
||||
def _source_game(
|
||||
paths: AppPaths,
|
||||
settings: Settings,
|
||||
_settings: Settings,
|
||||
argument: Path | None,
|
||||
) -> Path:
|
||||
"""Select the managed game unless an explicit diagnostic path was supplied.
|
||||
|
||||
The persisted ``game_source`` setting is an import seed only. It must not
|
||||
redirect normal Play, Prepare, or Login Trace operations away from the
|
||||
launcher-managed tree.
|
||||
"""
|
||||
|
||||
if argument is not None:
|
||||
return argument.expanduser().resolve()
|
||||
source_override = settings.source_override
|
||||
if source_override is not None:
|
||||
return source_override
|
||||
return paths.source_game
|
||||
|
||||
|
||||
@@ -679,10 +1063,19 @@ def _load_or_default_settings(paths: AppPaths, *, persist: bool) -> Settings:
|
||||
|
||||
|
||||
def _runner(paths: AppPaths, operation: str, dry_run: bool) -> CommandRunner:
|
||||
timestamp = datetime.now().astimezone().strftime("%Y%m%d-%H%M%S")
|
||||
timestamp = datetime.now().astimezone().strftime("%Y%m%d-%H%M%S-%f")
|
||||
return CommandRunner(paths.logs / f"{timestamp}-{operation}.jsonl", dry_run=dry_run)
|
||||
|
||||
|
||||
def _play_guard(
|
||||
paths: AppPaths,
|
||||
dry_run: bool,
|
||||
) -> AbstractContextManager[LauncherPlayLease]:
|
||||
if dry_run:
|
||||
return nullcontext(LauncherPlayLease.dry_run())
|
||||
return launcher_play_lock(paths.launcher_operation_lock)
|
||||
|
||||
|
||||
def _operation_guard(
|
||||
paths: AppPaths,
|
||||
operation: str,
|
||||
|
||||
@@ -2,16 +2,61 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from typing import Mapping, Sequence, TextIO
|
||||
from typing import Callable, Mapping, Sequence, TextIO
|
||||
|
||||
|
||||
OutputLineFilter = Callable[[str], str | None]
|
||||
MAX_CAPTURE_CHARACTERS = 1024 * 1024
|
||||
SUBPROCESS_ENCODING = "utf-8"
|
||||
SUBPROCESS_DECODE_ERRORS = "replace"
|
||||
|
||||
|
||||
class BoundedTextCapture:
|
||||
"""Retain the newest subprocess output without unbounded memory growth."""
|
||||
|
||||
def __init__(self, max_characters: int = MAX_CAPTURE_CHARACTERS) -> None:
|
||||
if max_characters <= 0:
|
||||
raise ValueError("capture size must be positive")
|
||||
self._max_characters = max_characters
|
||||
self._parts: deque[str] = deque()
|
||||
self._characters = 0
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def append(self, value: str) -> None:
|
||||
if not value:
|
||||
return
|
||||
with self._lock:
|
||||
if len(value) >= self._max_characters:
|
||||
self._parts.clear()
|
||||
self._parts.append(value[-self._max_characters:])
|
||||
self._characters = self._max_characters
|
||||
return
|
||||
self._parts.append(value)
|
||||
self._characters += len(value)
|
||||
while self._characters > self._max_characters:
|
||||
overflow = self._characters - self._max_characters
|
||||
oldest = self._parts[0]
|
||||
if len(oldest) <= overflow:
|
||||
self._parts.popleft()
|
||||
self._characters -= len(oldest)
|
||||
else:
|
||||
self._parts[0] = oldest[overflow:]
|
||||
self._characters -= overflow
|
||||
|
||||
def getvalue(self) -> str:
|
||||
with self._lock:
|
||||
return "".join(self._parts)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -53,6 +98,7 @@ class CommandRunner:
|
||||
timeout_seconds: float = 300.0,
|
||||
check: bool = True,
|
||||
stream_output: bool = False,
|
||||
output_line_filter: OutputLineFilter | None = None,
|
||||
) -> CommandOutcome:
|
||||
arguments = [str(value) for value in argv]
|
||||
if not arguments:
|
||||
@@ -81,6 +127,7 @@ class CommandRunner:
|
||||
environment=environment,
|
||||
timeout_seconds=timeout_seconds,
|
||||
started=started,
|
||||
output_line_filter=output_line_filter,
|
||||
)
|
||||
else:
|
||||
try:
|
||||
@@ -89,6 +136,8 @@ class CommandRunner:
|
||||
cwd=str(cwd) if cwd is not None else None,
|
||||
env=environment,
|
||||
text=True,
|
||||
encoding=SUBPROCESS_ENCODING,
|
||||
errors=SUBPROCESS_DECODE_ERRORS,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=timeout_seconds,
|
||||
@@ -97,8 +146,8 @@ class CommandRunner:
|
||||
outcome = CommandOutcome(
|
||||
argv=arguments,
|
||||
returncode=completed.returncode,
|
||||
stdout=completed.stdout,
|
||||
stderr=completed.stderr,
|
||||
stdout=_bounded_text(completed.stdout),
|
||||
stderr=_bounded_text(completed.stderr),
|
||||
duration_seconds=time.monotonic() - started,
|
||||
timed_out=False,
|
||||
dry_run=False,
|
||||
@@ -107,8 +156,8 @@ class CommandRunner:
|
||||
outcome = CommandOutcome(
|
||||
argv=arguments,
|
||||
returncode=None,
|
||||
stdout=_as_text(exc.stdout),
|
||||
stderr=_as_text(exc.stderr),
|
||||
stdout=_bounded_text(_as_text(exc.stdout)),
|
||||
stderr=_bounded_text(_as_text(exc.stderr)),
|
||||
duration_seconds=time.monotonic() - started,
|
||||
timed_out=True,
|
||||
dry_run=False,
|
||||
@@ -126,31 +175,43 @@ class CommandRunner:
|
||||
environment: Mapping[str, str],
|
||||
timeout_seconds: float,
|
||||
started: float,
|
||||
output_line_filter: OutputLineFilter | None,
|
||||
) -> CommandOutcome:
|
||||
process = subprocess.Popen(
|
||||
arguments,
|
||||
cwd=str(cwd) if cwd is not None else None,
|
||||
env=dict(environment),
|
||||
text=True,
|
||||
encoding=SUBPROCESS_ENCODING,
|
||||
errors=SUBPROCESS_DECODE_ERRORS,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
bufsize=1,
|
||||
start_new_session=True,
|
||||
)
|
||||
stdout: list[str] = []
|
||||
stderr: list[str] = []
|
||||
stdout = BoundedTextCapture()
|
||||
stderr = BoundedTextCapture()
|
||||
|
||||
def relay(
|
||||
stream: TextIO,
|
||||
capture: list[str],
|
||||
capture: BoundedTextCapture,
|
||||
destination: TextIO,
|
||||
) -> None:
|
||||
while True:
|
||||
line = stream.readline()
|
||||
if not line:
|
||||
return
|
||||
capture.append(line)
|
||||
destination.write(line)
|
||||
destination.flush()
|
||||
try:
|
||||
while True:
|
||||
line = stream.readline()
|
||||
if not line:
|
||||
return
|
||||
if output_line_filter is not None:
|
||||
line = output_line_filter(line)
|
||||
if line is None:
|
||||
continue
|
||||
capture.append(line)
|
||||
destination.write(line)
|
||||
destination.flush()
|
||||
except (OSError, ValueError):
|
||||
# Shutdown can close a pipe while its relay thread is leaving readline().
|
||||
return
|
||||
|
||||
assert process.stdout is not None
|
||||
assert process.stderr is not None
|
||||
@@ -171,17 +232,17 @@ class CommandRunner:
|
||||
returncode = process.wait(timeout=timeout_seconds)
|
||||
except subprocess.TimeoutExpired:
|
||||
timed_out = True
|
||||
process.kill()
|
||||
returncode = process.wait()
|
||||
stdout_thread.join()
|
||||
stderr_thread.join()
|
||||
process.stdout.close()
|
||||
process.stderr.close()
|
||||
returncode = _terminate_process_group(process)
|
||||
except BaseException:
|
||||
_terminate_process_group(process)
|
||||
_finish_stream_relays(process, stdout_thread, stderr_thread)
|
||||
raise
|
||||
_finish_stream_relays(process, stdout_thread, stderr_thread)
|
||||
return CommandOutcome(
|
||||
argv=arguments,
|
||||
returncode=None if timed_out else returncode,
|
||||
stdout="".join(stdout),
|
||||
stderr="".join(stderr),
|
||||
stdout=stdout.getvalue(),
|
||||
stderr=stderr.getvalue(),
|
||||
duration_seconds=time.monotonic() - started,
|
||||
timed_out=timed_out,
|
||||
dry_run=False,
|
||||
@@ -210,3 +271,53 @@ def _as_text(value: bytes | str | None) -> str:
|
||||
if isinstance(value, bytes):
|
||||
return value.decode("utf-8", errors="replace")
|
||||
return value
|
||||
|
||||
|
||||
def _bounded_text(value: str) -> str:
|
||||
return value[-MAX_CAPTURE_CHARACTERS:]
|
||||
|
||||
|
||||
def _terminate_process_group(
|
||||
process: subprocess.Popen[str],
|
||||
*,
|
||||
grace_seconds: float = 5.0,
|
||||
) -> int | None:
|
||||
"""Stop the complete child session, allowing Proton a short clean exit."""
|
||||
|
||||
if process.poll() is not None:
|
||||
return process.returncode
|
||||
try:
|
||||
os.killpg(process.pid, signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
return process.poll()
|
||||
try:
|
||||
return process.wait(timeout=grace_seconds)
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
try:
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
return process.poll()
|
||||
try:
|
||||
return process.wait(timeout=grace_seconds)
|
||||
except subprocess.TimeoutExpired:
|
||||
return process.poll()
|
||||
|
||||
|
||||
def _finish_stream_relays(
|
||||
process: subprocess.Popen[str],
|
||||
stdout_thread: threading.Thread,
|
||||
stderr_thread: threading.Thread,
|
||||
) -> None:
|
||||
"""Bound relay cleanup so a second interrupt can never be required."""
|
||||
|
||||
stdout_thread.join(timeout=2.0)
|
||||
stderr_thread.join(timeout=2.0)
|
||||
for stream in (process.stdout, process.stderr):
|
||||
if stream is not None:
|
||||
try:
|
||||
stream.close()
|
||||
except OSError:
|
||||
pass
|
||||
stdout_thread.join(timeout=0.25)
|
||||
stderr_thread.join(timeout=0.25)
|
||||
|
||||
213
ptlinux/dashboard_debug.py
Normal file
213
ptlinux/dashboard_debug.py
Normal file
@@ -0,0 +1,213 @@
|
||||
"""Read-only state snapshots for the launcher's hidden debug view."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
from .import_state import game_import_requires_verification
|
||||
from .paths import AppPaths
|
||||
from .runtime import (
|
||||
RuntimeErrorPT,
|
||||
RuntimeSelection,
|
||||
resolve_proton_sync_mode,
|
||||
select_runtime,
|
||||
)
|
||||
from .settings import Settings
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RuntimeDebugInfo:
|
||||
"""Configured, selected, and last-prepared runtime state."""
|
||||
|
||||
requested_kind: str
|
||||
selected_kind: str | None
|
||||
executable: str | None
|
||||
proton_path: str | None
|
||||
prefix: str | None
|
||||
sync_configured: str
|
||||
sync_effective: str | None
|
||||
prepared_kind: str | None
|
||||
prepared_proton_path: str | None
|
||||
prepared_at: str | None
|
||||
marker_matches_selection: bool | None
|
||||
dxvk_d3d9_override: str | None
|
||||
error: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VerificationDebugInfo:
|
||||
"""Cached trust evidence from completed depot and import operations."""
|
||||
|
||||
import_state: str
|
||||
import_verified_at: str | None
|
||||
import_build: int | None
|
||||
last_sync_at: str | None
|
||||
last_full_verification_at: str | None
|
||||
build: int | None
|
||||
version: str | None
|
||||
mode: str | None
|
||||
files_total: int | None
|
||||
files_downloaded: int | None
|
||||
files_deleted: int | None
|
||||
files_created: int | None
|
||||
files_quarantined: int | None
|
||||
|
||||
|
||||
RuntimeSelector = Callable[[Settings], RuntimeSelection]
|
||||
SyncResolver = Callable[[str], str]
|
||||
|
||||
|
||||
def inspect_runtime_debug(
|
||||
paths: AppPaths,
|
||||
settings: Settings,
|
||||
*,
|
||||
selector: RuntimeSelector = select_runtime,
|
||||
sync_resolver: SyncResolver = resolve_proton_sync_mode,
|
||||
) -> RuntimeDebugInfo:
|
||||
"""Resolve the next runtime and compare it with the preparation marker."""
|
||||
|
||||
marker = _read_json_object(paths.game_runtime_marker)
|
||||
prepared_runtime: dict[str, object] | None = None
|
||||
if marker is not None:
|
||||
raw_prepared_runtime = marker.get("runtime")
|
||||
if isinstance(raw_prepared_runtime, dict):
|
||||
prepared_runtime = raw_prepared_runtime
|
||||
prepared_kind = _text(prepared_runtime.get("kind")) if prepared_runtime else None
|
||||
prepared_proton_path = (
|
||||
_text(prepared_runtime.get("proton_path")) if prepared_runtime else None
|
||||
)
|
||||
prepared_at = _text(marker.get("configured_at")) if marker else None
|
||||
dxvk_override = _text(marker.get("dxvk_d3d9_override")) if marker else None
|
||||
selection: RuntimeSelection | None = None
|
||||
error: str | None = None
|
||||
try:
|
||||
selection = selector(settings)
|
||||
except (OSError, RuntimeErrorPT, ValueError) as exc:
|
||||
error = str(exc).strip() or type(exc).__name__
|
||||
selected_kind = selection.kind if selection is not None else None
|
||||
prefix_kind = selected_kind or prepared_kind
|
||||
prefix = None
|
||||
if prefix_kind == "umu":
|
||||
prefix = str(paths.umu_prefix)
|
||||
elif prefix_kind in {"proton7", "proton7-container"}:
|
||||
prefix = str(paths.proton_prefix)
|
||||
sync_effective = None
|
||||
if prefix_kind in {"proton7", "proton7-container"}:
|
||||
try:
|
||||
sync_effective = sync_resolver(settings.proton_sync)
|
||||
except ValueError as exc:
|
||||
error = error or (str(exc).strip() or type(exc).__name__)
|
||||
marker_matches = (
|
||||
prepared_runtime == asdict(selection)
|
||||
if prepared_runtime is not None and selection is not None
|
||||
else None
|
||||
)
|
||||
return RuntimeDebugInfo(
|
||||
requested_kind=settings.runtime,
|
||||
selected_kind=selected_kind,
|
||||
executable=selection.executable if selection is not None else None,
|
||||
proton_path=selection.proton_path if selection is not None else None,
|
||||
prefix=prefix,
|
||||
sync_configured=settings.proton_sync,
|
||||
sync_effective=sync_effective,
|
||||
prepared_kind=prepared_kind,
|
||||
prepared_proton_path=prepared_proton_path,
|
||||
prepared_at=prepared_at,
|
||||
marker_matches_selection=marker_matches,
|
||||
dxvk_d3d9_override=dxvk_override,
|
||||
error=error,
|
||||
)
|
||||
|
||||
|
||||
def read_verification_debug(paths: AppPaths) -> VerificationDebugInfo:
|
||||
"""Read cached verification state without hashing or changing game files."""
|
||||
|
||||
depot = _read_json_object(paths.native_depot_state)
|
||||
if depot is not None and depot.get("dry_run") is not False:
|
||||
depot = None
|
||||
import_manifest_exists = paths.game_import_manifest.exists()
|
||||
import_manifest = _read_json_object(paths.game_import_manifest)
|
||||
if not import_manifest_exists:
|
||||
import_state = "not applicable"
|
||||
elif game_import_requires_verification(paths):
|
||||
import_state = "pending"
|
||||
else:
|
||||
import_state = "verified"
|
||||
import_verified_at = (
|
||||
_text(import_manifest.get("verification_completed_at"))
|
||||
if import_state == "verified" and import_manifest is not None
|
||||
else None
|
||||
)
|
||||
import_build = (
|
||||
_non_negative_int(import_manifest.get("verification_build"))
|
||||
if import_state == "verified" and import_manifest is not None
|
||||
else None
|
||||
)
|
||||
last_sync_at = _text(depot.get("completed_at")) if depot else None
|
||||
full_verifications = [
|
||||
value
|
||||
for value in (
|
||||
last_sync_at if depot is not None and depot.get("repair") is True else None,
|
||||
import_verified_at,
|
||||
)
|
||||
if value is not None
|
||||
]
|
||||
last_full_verification_at = (
|
||||
max(full_verifications, key=_iso_timestamp)
|
||||
if full_verifications
|
||||
else None
|
||||
)
|
||||
return VerificationDebugInfo(
|
||||
import_state=import_state,
|
||||
import_verified_at=import_verified_at,
|
||||
import_build=import_build,
|
||||
last_sync_at=last_sync_at,
|
||||
last_full_verification_at=last_full_verification_at,
|
||||
build=_non_negative_int(depot.get("build")) if depot else None,
|
||||
version=_text(depot.get("version")) if depot else None,
|
||||
mode=(
|
||||
"full repair"
|
||||
if depot is not None and depot.get("repair") is True
|
||||
else "update"
|
||||
if depot is not None and depot.get("repair") is False
|
||||
else None
|
||||
),
|
||||
files_total=_non_negative_int(depot.get("files_total")) if depot else None,
|
||||
files_downloaded=(
|
||||
_non_negative_int(depot.get("files_downloaded")) if depot else None
|
||||
),
|
||||
files_deleted=_non_negative_int(depot.get("files_deleted")) if depot else None,
|
||||
files_created=_non_negative_int(depot.get("files_created")) if depot else None,
|
||||
files_quarantined=(
|
||||
_non_negative_int(depot.get("files_quarantined")) if depot else None
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _read_json_object(path: Path) -> dict[str, object] | None:
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeError, json.JSONDecodeError):
|
||||
return None
|
||||
return value if isinstance(value, dict) else None
|
||||
|
||||
|
||||
def _text(value: object) -> str | None:
|
||||
return value.strip() if isinstance(value, str) and value.strip() else None
|
||||
|
||||
|
||||
def _non_negative_int(value: object) -> int | None:
|
||||
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
def _iso_timestamp(value: str) -> float:
|
||||
try:
|
||||
return datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp()
|
||||
except (OverflowError, ValueError):
|
||||
return float("-inf")
|
||||
368
ptlinux/depot.py
368
ptlinux/depot.py
@@ -13,6 +13,7 @@ import json
|
||||
import os
|
||||
from pathlib import Path, PurePosixPath
|
||||
import re
|
||||
import stat
|
||||
import threading
|
||||
import time
|
||||
from typing import Callable, Iterable, Mapping, Protocol
|
||||
@@ -38,8 +39,41 @@ DOWNLOAD_RETRY_DELAYS_SECONDS = (1.0, 2.0, 4.0, 8.0, 16.0)
|
||||
PROGRESS_UPDATE_INTERVAL_SECONDS = 0.075
|
||||
MAX_CREATE_IF_MISSING_BYTES = 16 * 1024 * 1024
|
||||
MAX_LINE_ENDING_RECOVERY_BYTES = 1024 * 1024
|
||||
UNMANAGED_EXECUTABLE_SUFFIXES = frozenset(
|
||||
{
|
||||
".asi",
|
||||
".bat",
|
||||
".cmd",
|
||||
".com",
|
||||
".cpl",
|
||||
".desktop",
|
||||
".dll",
|
||||
".drv",
|
||||
".exe",
|
||||
".jar",
|
||||
".js",
|
||||
".lnk",
|
||||
".lua",
|
||||
".msi",
|
||||
".ocx",
|
||||
".ps1",
|
||||
".py",
|
||||
".reg",
|
||||
".scr",
|
||||
".sh",
|
||||
".so",
|
||||
".url",
|
||||
".vbe",
|
||||
".vbs",
|
||||
".wsf",
|
||||
}
|
||||
)
|
||||
ALWAYS_VERIFY_PATHS = frozenset(
|
||||
{"game.exe", "game.dll", "gamese.exe", "gamese.dll"}
|
||||
)
|
||||
SHA1_PATTERN = re.compile(r"^[0-9a-fA-F]{40}$")
|
||||
CONTENT_RANGE_PATTERN = re.compile(r"^bytes\s+(\d+)-(\d+)/(\d+)$", re.IGNORECASE)
|
||||
DEPOT_LAUNCH_CATALOG_SCHEMA = 1
|
||||
|
||||
|
||||
class DepotError(RuntimeError):
|
||||
@@ -72,6 +106,22 @@ class CreateIfMissing:
|
||||
content_base64: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DepotStartOption:
|
||||
"""One publisher-advertised realm launch target."""
|
||||
|
||||
name: str
|
||||
path: str
|
||||
arguments: str
|
||||
|
||||
|
||||
DEFAULT_DEPOT_START_OPTION = DepotStartOption(
|
||||
name="Babel",
|
||||
path="Game.exe",
|
||||
arguments="PTEU_REBORN",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DepotSnapshot:
|
||||
"""The current, verified shape of the public game depot."""
|
||||
@@ -84,6 +134,101 @@ class DepotSnapshot:
|
||||
files: tuple[DepotFile, ...]
|
||||
deleted_files: tuple[str, ...]
|
||||
create_if_missing: tuple[CreateIfMissing, ...]
|
||||
start_options: tuple[DepotStartOption, ...] = (DEFAULT_DEPOT_START_OPTION,)
|
||||
default_start_option: str = DEFAULT_DEPOT_START_OPTION.name
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DepotLaunchCatalog:
|
||||
"""Small cached subset of a depot manifest needed for offline Play."""
|
||||
|
||||
build: int
|
||||
start_options: tuple[DepotStartOption, ...]
|
||||
default_start_option: str
|
||||
|
||||
|
||||
def launch_catalog_from_snapshot(snapshot: DepotSnapshot) -> DepotLaunchCatalog:
|
||||
return DepotLaunchCatalog(
|
||||
build=snapshot.build,
|
||||
start_options=snapshot.start_options,
|
||||
default_start_option=snapshot.default_start_option,
|
||||
)
|
||||
|
||||
|
||||
def select_depot_start_option(
|
||||
catalog: DepotLaunchCatalog | None,
|
||||
preferred_realm: str | None = None,
|
||||
) -> DepotStartOption:
|
||||
"""Choose a published realm, falling back to Babel when possible."""
|
||||
|
||||
current = catalog or DepotLaunchCatalog(
|
||||
build=0,
|
||||
start_options=(DEFAULT_DEPOT_START_OPTION,),
|
||||
default_start_option=DEFAULT_DEPOT_START_OPTION.name,
|
||||
)
|
||||
if preferred_realm:
|
||||
preferred = preferred_realm.casefold()
|
||||
for option in current.start_options:
|
||||
if option.name.casefold() == preferred:
|
||||
return option
|
||||
for option in current.start_options:
|
||||
if option.name.casefold() == DEFAULT_DEPOT_START_OPTION.name.casefold():
|
||||
return option
|
||||
for option in current.start_options:
|
||||
if option.name.casefold() == current.default_start_option.casefold():
|
||||
return option
|
||||
return current.start_options[0]
|
||||
|
||||
|
||||
def cache_depot_launch_catalog(path: Path, snapshot: DepotSnapshot) -> None:
|
||||
"""Cache only publisher-advertised launch metadata for network-free Play."""
|
||||
|
||||
catalog = launch_catalog_from_snapshot(snapshot)
|
||||
_write_json(
|
||||
path,
|
||||
{
|
||||
"schema": DEPOT_LAUNCH_CATALOG_SCHEMA,
|
||||
"build": catalog.build,
|
||||
"default_start_option": catalog.default_start_option,
|
||||
"start_options": [asdict(option) for option in catalog.start_options],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def read_cached_depot_launch_catalog(path: Path) -> DepotLaunchCatalog | None:
|
||||
"""Read the last valid launch catalog without performing network I/O."""
|
||||
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(value, dict):
|
||||
return None
|
||||
if value.get("schema") != DEPOT_LAUNCH_CATALOG_SCHEMA:
|
||||
return None
|
||||
build = _positive_int(value.get("build"), "cached depot build")
|
||||
raw_options = value.get("start_options")
|
||||
if not isinstance(raw_options, list):
|
||||
return None
|
||||
manifest_options = [
|
||||
{
|
||||
"Name": item.get("name"),
|
||||
"Path": item.get("path"),
|
||||
"Arguments": item.get("arguments"),
|
||||
}
|
||||
if isinstance(item, dict)
|
||||
else item
|
||||
for item in raw_options
|
||||
]
|
||||
options, default_name = _parse_start_options(
|
||||
manifest_options,
|
||||
value.get("default_start_option"),
|
||||
)
|
||||
except (OSError, UnicodeError, json.JSONDecodeError, DepotError):
|
||||
return None
|
||||
return DepotLaunchCatalog(
|
||||
build=build,
|
||||
start_options=options,
|
||||
default_start_option=default_name,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -129,11 +274,21 @@ class DepotSyncResult:
|
||||
bytes_downloaded: int
|
||||
files_deleted: int
|
||||
files_created: int
|
||||
files_quarantined: int
|
||||
quarantine_directory: str | None
|
||||
repair: bool
|
||||
dry_run: bool
|
||||
completed_at: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class QuarantineResult:
|
||||
"""Recoverable unmanaged entries removed before authenticating an import."""
|
||||
|
||||
files: int
|
||||
directory: str | None
|
||||
|
||||
|
||||
ProgressCallback = Callable[[SyncProgress], None]
|
||||
|
||||
|
||||
@@ -453,7 +608,11 @@ def plan_update(
|
||||
continue
|
||||
except OSError as exc:
|
||||
raise DepotError(f"cannot inspect {file.path}: {exc}") from exc
|
||||
if repair or file.build == snapshot.build:
|
||||
if (
|
||||
repair
|
||||
or file.build == snapshot.build
|
||||
or file.path.casefold() in ALWAYS_VERIFY_PATHS
|
||||
):
|
||||
if _sha1(target).casefold() != file.sha1.casefold():
|
||||
downloads.append(PlannedDownload(file, "hash mismatch"))
|
||||
|
||||
@@ -479,6 +638,7 @@ def sync_managed_game(
|
||||
paths: AppPaths,
|
||||
*,
|
||||
repair: bool = False,
|
||||
quarantine_unmanaged: bool = False,
|
||||
dry_run: bool = False,
|
||||
progress: ProgressCallback | None = None,
|
||||
client: DepotClient | None = None,
|
||||
@@ -489,6 +649,9 @@ def sync_managed_game(
|
||||
depot_client = client or DepotClient()
|
||||
_emit_progress(progress, "manifest", 0, 0, 0, 0, None)
|
||||
snapshot = depot_client.fetch_snapshot()
|
||||
quarantine = QuarantineResult(0, None)
|
||||
if quarantine_unmanaged and not dry_run:
|
||||
quarantine = _quarantine_unmanaged_files(paths, snapshot)
|
||||
plan = plan_update(paths.source_game, snapshot, repair=repair)
|
||||
_emit_progress(
|
||||
progress,
|
||||
@@ -501,7 +664,16 @@ def sync_managed_game(
|
||||
)
|
||||
|
||||
if dry_run:
|
||||
return _sync_result(snapshot, plan, 0, 0, 0, repair, True)
|
||||
return _sync_result(
|
||||
snapshot,
|
||||
plan,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
repair,
|
||||
True,
|
||||
quarantine=quarantine,
|
||||
)
|
||||
|
||||
root = _prepare_game_root(paths.source_game, create=True)
|
||||
_download_plan(depot_client, snapshot, root, plan, progress)
|
||||
@@ -516,8 +688,10 @@ def sync_managed_game(
|
||||
repair,
|
||||
False,
|
||||
created=created,
|
||||
quarantine=quarantine,
|
||||
)
|
||||
_write_json(paths.native_depot_state, asdict(result))
|
||||
cache_depot_launch_catalog(paths.depot_launch_catalog_cache, snapshot)
|
||||
_emit_progress(
|
||||
progress,
|
||||
"complete",
|
||||
@@ -530,6 +704,110 @@ def sync_managed_game(
|
||||
return result
|
||||
|
||||
|
||||
def _quarantine_unmanaged_files(
|
||||
paths: AppPaths,
|
||||
snapshot: DepotSnapshot,
|
||||
) -> QuarantineResult:
|
||||
"""Move non-depot import contents aside without following links."""
|
||||
|
||||
root = _prepare_game_root(paths.source_game, create=False)
|
||||
if not root.exists():
|
||||
return QuarantineResult(0, None)
|
||||
allowed = {
|
||||
file.path for file in snapshot.files
|
||||
} | {
|
||||
entry.path for entry in snapshot.create_if_missing
|
||||
}
|
||||
candidates: list[tuple[Path, str]] = []
|
||||
|
||||
def walk_error(error: OSError) -> None:
|
||||
raise DepotError(f"cannot inspect imported game tree: {error}") from error
|
||||
|
||||
for directory, directory_names, file_names in os.walk(
|
||||
root,
|
||||
topdown=True,
|
||||
followlinks=False,
|
||||
onerror=walk_error,
|
||||
):
|
||||
parent = Path(directory)
|
||||
for name in tuple(directory_names):
|
||||
path = parent / name
|
||||
try:
|
||||
status = path.lstat()
|
||||
except OSError as exc:
|
||||
raise DepotError(f"cannot inspect imported entry {path}: {exc}") from exc
|
||||
if stat.S_ISDIR(status.st_mode):
|
||||
continue
|
||||
directory_names.remove(name)
|
||||
candidates.append((path, path.relative_to(root).as_posix()))
|
||||
for name in file_names:
|
||||
path = parent / name
|
||||
relative = path.relative_to(root).as_posix()
|
||||
try:
|
||||
status = path.lstat()
|
||||
except OSError as exc:
|
||||
raise DepotError(f"cannot inspect imported entry {path}: {exc}") from exc
|
||||
if not stat.S_ISREG(status.st_mode):
|
||||
candidates.append((path, relative))
|
||||
continue
|
||||
if relative in allowed:
|
||||
continue
|
||||
if _safe_imported_user_file(path, relative):
|
||||
continue
|
||||
candidates.append((path, relative))
|
||||
|
||||
if not candidates:
|
||||
return QuarantineResult(0, None)
|
||||
base = paths.game_import_quarantine.resolve(strict=False)
|
||||
data_root = paths.data.resolve(strict=False)
|
||||
try:
|
||||
base.relative_to(data_root)
|
||||
except ValueError as exc:
|
||||
raise DepotError(f"import quarantine is outside the data root: {base}") from exc
|
||||
stamp = datetime.now().astimezone().strftime("%Y%m%d-%H%M%S")
|
||||
quarantine = base / f"{stamp}-{uuid4().hex}"
|
||||
files_root = quarantine / "files"
|
||||
files_root.mkdir(parents=True, exist_ok=False)
|
||||
manifest = {
|
||||
"schema": 1,
|
||||
"complete": False,
|
||||
"created_at": datetime.now().astimezone().isoformat(),
|
||||
"source": str(root),
|
||||
"files": [relative for _path, relative in candidates],
|
||||
}
|
||||
_write_json(quarantine / "quarantine.json", manifest)
|
||||
for source, relative in candidates:
|
||||
destination = files_root.joinpath(*PurePosixPath(relative).parts)
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
source.replace(destination)
|
||||
except OSError as exc:
|
||||
raise DepotError(
|
||||
f"could not quarantine imported entry {relative}: {exc}"
|
||||
) from exc
|
||||
manifest["complete"] = True
|
||||
_write_json(quarantine / "quarantine.json", manifest)
|
||||
return QuarantineResult(len(candidates), str(quarantine))
|
||||
|
||||
|
||||
def _safe_imported_user_file(path: Path, relative: str) -> bool:
|
||||
parts = PurePosixPath(relative).parts
|
||||
if not parts or parts[0].casefold() != "save":
|
||||
return False
|
||||
if path.suffix.casefold() in UNMANAGED_EXECUTABLE_SUFFIXES:
|
||||
return False
|
||||
try:
|
||||
with path.open("rb") as handle:
|
||||
prefix = handle.read(4)
|
||||
except OSError as exc:
|
||||
raise DepotError(f"cannot inspect imported user file {path}: {exc}") from exc
|
||||
return not (
|
||||
prefix.startswith(b"MZ")
|
||||
or prefix.startswith(b"\x7fELF")
|
||||
or prefix.startswith(b"#!")
|
||||
)
|
||||
|
||||
|
||||
def _download_plan(
|
||||
client: DepotClient,
|
||||
depot_snapshot: DepotSnapshot,
|
||||
@@ -692,6 +970,7 @@ def _sync_result(
|
||||
dry_run: bool,
|
||||
*,
|
||||
created: int = 0,
|
||||
quarantine: QuarantineResult = QuarantineResult(0, None),
|
||||
) -> DepotSyncResult:
|
||||
return DepotSyncResult(
|
||||
region=snapshot.region,
|
||||
@@ -704,6 +983,8 @@ def _sync_result(
|
||||
bytes_downloaded=bytes_downloaded,
|
||||
files_deleted=files_deleted,
|
||||
files_created=created,
|
||||
files_quarantined=quarantine.files,
|
||||
quarantine_directory=quarantine.directory,
|
||||
repair=repair,
|
||||
dry_run=dry_run,
|
||||
completed_at=datetime.now().astimezone().isoformat(),
|
||||
@@ -745,6 +1026,19 @@ def _parse_snapshot(
|
||||
|
||||
deleted = _parse_deleted_paths(build_manifest.get("df"), seen_paths)
|
||||
create_if_missing = _parse_create_if_missing(depot_manifest.get("CreateIfMissing"))
|
||||
start_options, default_start_option = _parse_start_options(
|
||||
depot_manifest.get("StartOptions"),
|
||||
depot_manifest.get("DefaultStartOption"),
|
||||
)
|
||||
canonical_file_paths = {file.path.casefold(): file.path for file in files}
|
||||
start_options = tuple(
|
||||
DepotStartOption(
|
||||
name=option.name,
|
||||
path=canonical_file_paths.get(option.path.casefold(), option.path),
|
||||
arguments=option.arguments,
|
||||
)
|
||||
for option in start_options
|
||||
)
|
||||
return DepotSnapshot(
|
||||
region=region,
|
||||
server=server,
|
||||
@@ -754,6 +1048,8 @@ def _parse_snapshot(
|
||||
files=tuple(files),
|
||||
deleted_files=deleted,
|
||||
create_if_missing=create_if_missing,
|
||||
start_options=start_options,
|
||||
default_start_option=default_start_option,
|
||||
)
|
||||
|
||||
|
||||
@@ -798,6 +1094,74 @@ def _parse_create_if_missing(value: object) -> tuple[CreateIfMissing, ...]:
|
||||
return tuple(entries)
|
||||
|
||||
|
||||
def _parse_start_options(
|
||||
value: object,
|
||||
default_value: object,
|
||||
) -> tuple[tuple[DepotStartOption, ...], str]:
|
||||
"""Validate the launch targets published by the official depot manifest."""
|
||||
|
||||
# Older test fixtures and historical manifests did not expose StartOptions.
|
||||
# Babel remains the launcher-safe compatibility default for that case.
|
||||
if value is None:
|
||||
return (DEFAULT_DEPOT_START_OPTION,), DEFAULT_DEPOT_START_OPTION.name
|
||||
if not isinstance(value, list) or not value:
|
||||
raise DepotError("StartOptions must be a non-empty array")
|
||||
options: list[DepotStartOption] = []
|
||||
seen_names: set[str] = set()
|
||||
for item in value:
|
||||
if not isinstance(item, dict):
|
||||
raise DepotError("StartOptions contains a non-object entry")
|
||||
name = _start_option_text(item.get("Name"), "StartOptions Name", 64)
|
||||
folded_name = name.casefold()
|
||||
if folded_name in seen_names:
|
||||
raise DepotError(f"StartOptions has duplicate realm name: {name}")
|
||||
seen_names.add(folded_name)
|
||||
path = _normalise_relative_path(item.get("Path"), f"StartOptions path for {name}")
|
||||
if PurePosixPath(path).suffix.casefold() != ".exe":
|
||||
raise DepotError(f"StartOptions path is not an executable for {name}: {path}")
|
||||
arguments = _start_option_text(
|
||||
item.get("Arguments"),
|
||||
f"StartOptions arguments for {name}",
|
||||
2048,
|
||||
)
|
||||
options.append(DepotStartOption(name=name, path=path, arguments=arguments))
|
||||
|
||||
if default_value is None:
|
||||
default_name = next(
|
||||
(
|
||||
option.name
|
||||
for option in options
|
||||
if option.name.casefold() == DEFAULT_DEPOT_START_OPTION.name.casefold()
|
||||
),
|
||||
options[0].name,
|
||||
)
|
||||
else:
|
||||
default_name = _start_option_text(
|
||||
default_value,
|
||||
"DefaultStartOption",
|
||||
64,
|
||||
)
|
||||
matching_default = next(
|
||||
(option.name for option in options if option.name.casefold() == default_name.casefold()),
|
||||
None,
|
||||
)
|
||||
if matching_default is None:
|
||||
raise DepotError(
|
||||
f"DefaultStartOption does not match an advertised realm: {default_name}"
|
||||
)
|
||||
default_name = matching_default
|
||||
return tuple(options), default_name
|
||||
|
||||
|
||||
def _start_option_text(value: object, field: str, maximum_length: int) -> str:
|
||||
if not isinstance(value, str):
|
||||
raise DepotError(f"{field} must be a string")
|
||||
text = value.strip()
|
||||
if not text or len(text) > maximum_length or any(ord(character) < 32 for character in text):
|
||||
raise DepotError(f"invalid {field}")
|
||||
return text
|
||||
|
||||
|
||||
def _decode_create_if_missing(entry: CreateIfMissing) -> bytes:
|
||||
try:
|
||||
content = b64decode(entry.content_base64, validate=True)
|
||||
|
||||
59
ptlinux/desktop_identity.py
Normal file
59
ptlinux/desktop_identity.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""Stable desktop identity shared by the launcher and Wine game window."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Mapping
|
||||
|
||||
|
||||
APPLICATION_NAME = "PristonTale EU"
|
||||
APPLICATION_WM_CLASS = "PristonTaleEU"
|
||||
APPLICATION_WM_INSTANCE = "pristontale-eu"
|
||||
APPLICATION_ICON_FILENAME = "eu.pristontale.PTLinux.png"
|
||||
APPLICATION_ICON_VARIABLE = "PTLINUX_ICON_PATH"
|
||||
|
||||
|
||||
def find_application_icon(
|
||||
environment: Mapping[str, str] | None = None,
|
||||
) -> Path | None:
|
||||
"""Find the installed icon, with source-tree fallbacks for development."""
|
||||
|
||||
env = os.environ if environment is None else environment
|
||||
candidates: list[Path] = []
|
||||
explicit = env.get(APPLICATION_ICON_VARIABLE)
|
||||
if explicit:
|
||||
candidates.append(Path(explicit).expanduser())
|
||||
|
||||
# Some distributions bundle the icon beside this module. The installer
|
||||
# publishes the same asset through the user's XDG data directory.
|
||||
candidates.append(Path(__file__).with_name(APPLICATION_ICON_FILENAME))
|
||||
home_value = env.get("HOME")
|
||||
if home_value:
|
||||
home = Path(home_value).expanduser()
|
||||
data_home_value = env.get("XDG_DATA_HOME")
|
||||
data_home = (
|
||||
Path(data_home_value).expanduser()
|
||||
if data_home_value
|
||||
else home / ".local/share"
|
||||
)
|
||||
candidates.append(data_home / "pixmaps" / APPLICATION_ICON_FILENAME)
|
||||
default_icon = home / ".local/share/pixmaps" / APPLICATION_ICON_FILENAME
|
||||
if default_icon not in candidates:
|
||||
candidates.append(default_icon)
|
||||
|
||||
# The source checkout keeps installer assets under packaging/.
|
||||
candidates.append(
|
||||
Path(__file__).resolve().parent.parent
|
||||
/ "packaging"
|
||||
/ APPLICATION_ICON_FILENAME
|
||||
)
|
||||
seen: set[Path] = set()
|
||||
for candidate in candidates:
|
||||
resolved = candidate.resolve(strict=False)
|
||||
if resolved in seen:
|
||||
continue
|
||||
seen.add(resolved)
|
||||
if resolved.is_file():
|
||||
return resolved
|
||||
return None
|
||||
@@ -11,6 +11,12 @@ import shutil
|
||||
from typing import Mapping
|
||||
|
||||
from .launcher import find_wine_tools
|
||||
from .runtime import find_steam_proton7
|
||||
|
||||
|
||||
ARCH_FAMILY_DISTRIBUTIONS = frozenset(
|
||||
{"arch", "cachyos", "endeavouros", "garuda", "manjaro"}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -70,8 +76,8 @@ def inspect_host(environment: Mapping[str, str] | None = None) -> DoctorReport:
|
||||
ToolStatus(
|
||||
"gamescope",
|
||||
shutil.which("gamescope"),
|
||||
True,
|
||||
"borderless X11/Wayland game container",
|
||||
False,
|
||||
"optional manual game-container diagnostics",
|
||||
),
|
||||
ToolStatus(
|
||||
"cabextract",
|
||||
@@ -88,8 +94,8 @@ def inspect_host(environment: Mapping[str, str] | None = None) -> DoctorReport:
|
||||
ToolStatus(
|
||||
"umu-run",
|
||||
shutil.which("umu-run"),
|
||||
False,
|
||||
"portable Proton runtime when Steam Proton 7 is unavailable",
|
||||
distribution in ARCH_FAMILY_DISTRIBUTIONS,
|
||||
"current portable Proton runtime; automatic on Arch-family hosts",
|
||||
),
|
||||
ToolStatus(
|
||||
"vulkaninfo",
|
||||
@@ -98,6 +104,7 @@ def inspect_host(environment: Mapping[str, str] | None = None) -> DoctorReport:
|
||||
"Vulkan diagnostics",
|
||||
),
|
||||
]
|
||||
tools.extend(_platform_optional_tools(distribution, architecture=platform.machine()))
|
||||
warnings: list[str] = []
|
||||
if platform.machine().casefold() not in {"x86_64", "amd64"}:
|
||||
warnings.append("PristonTale EU requires an x86-64 host with 32-bit Vulkan support.")
|
||||
@@ -107,6 +114,7 @@ def inspect_host(environment: Mapping[str, str] | None = None) -> DoctorReport:
|
||||
warnings.append(
|
||||
"No game runtime was found: install umu-launcher or Steam Proton 7.0."
|
||||
)
|
||||
warnings.extend(_arch_runtime_warnings(distribution, platform.machine()))
|
||||
required_ok = all(item.path is not None for item in tools if item.required)
|
||||
return DoctorReport(
|
||||
ok=required_ok and not any("No game runtime" in warning for warning in warnings),
|
||||
@@ -147,38 +155,78 @@ def _python_tk_path() -> str | None:
|
||||
def _package_hint(distribution: str) -> str | None:
|
||||
if distribution in {"ubuntu", "debian", "linuxmint", "pop"}:
|
||||
return (
|
||||
"Install Python/Tk, Wine (64- and 32-bit), Winetricks, Gamescope, "
|
||||
"Install Python/Tk, Wine (64- and 32-bit), Winetricks, "
|
||||
"Cabextract, OpenSSL, and both 64- and 32-bit Vulkan drivers with APT."
|
||||
)
|
||||
if distribution in {"fedora", "nobara"}:
|
||||
return (
|
||||
"Install python3-tkinter, wine, winetricks, gamescope, cabextract, openssl, "
|
||||
"Install python3-tkinter, wine, winetricks, cabextract, openssl, "
|
||||
"and the matching 32-bit Vulkan driver with DNF."
|
||||
)
|
||||
if distribution in {"arch", "cachyos", "endeavouros", "manjaro"}:
|
||||
if distribution in ARCH_FAMILY_DISTRIBUTIONS:
|
||||
return (
|
||||
"Install python, tk, wine, winetricks, gamescope, cabextract, openssl, and "
|
||||
"the matching lib32 Vulkan driver with Pacman."
|
||||
"Install python, tk, wine, winetricks, umu-launcher, cabextract, "
|
||||
"openssl, and the matching lib32 Vulkan and gstreamer dependencies with Pacman."
|
||||
)
|
||||
if distribution in {"opensuse-tumbleweed", "opensuse-leap", "suse"}:
|
||||
return (
|
||||
"Install Python/Tk, Wine, Winetricks, Gamescope, Cabextract, OpenSSL, and "
|
||||
"Install Python/Tk, Wine, Winetricks, Cabextract, OpenSSL, and "
|
||||
"32-bit Vulkan support with Zypper."
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
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
|
||||
return find_steam_proton7(environment) is not None
|
||||
|
||||
|
||||
def _platform_optional_tools(distribution: str, *, architecture: str) -> list[ToolStatus]:
|
||||
if architecture.casefold() not in {"x86_64", "amd64"}:
|
||||
return []
|
||||
if distribution in ARCH_FAMILY_DISTRIBUTIONS:
|
||||
return [
|
||||
_optional_tool_status(
|
||||
"lib32-vulkan",
|
||||
"/usr/lib32/libvulkan.so.1",
|
||||
"32-bit Vulkan runtime compatibility layer",
|
||||
),
|
||||
_optional_tool_status(
|
||||
"lib32-gstreamer",
|
||||
"/usr/lib32/gstreamer-1.0",
|
||||
"32-bit gstreamer runtime compatibility modules",
|
||||
),
|
||||
]
|
||||
return []
|
||||
|
||||
|
||||
def _arch_runtime_warnings(distribution: str, architecture: str) -> list[str]:
|
||||
if distribution not in ARCH_FAMILY_DISTRIBUTIONS:
|
||||
return []
|
||||
if architecture.casefold() not in {"x86_64", "amd64"}:
|
||||
return [
|
||||
"Non-x86_64 Linux hosts are not a supported runtime architecture for this game."
|
||||
]
|
||||
warnings: list[str] = []
|
||||
if not _path_exists("/usr/lib32/libvulkan.so.1"):
|
||||
warnings.append(
|
||||
"Install lib32-vulkan-icd-loader and the GPU vendor's matching 32-bit "
|
||||
"Vulkan driver; this 32-bit D3D9 client otherwise cannot initialize DXVK."
|
||||
)
|
||||
if not _path_exists("/usr/lib32/gstreamer-1.0"):
|
||||
warnings.append(
|
||||
"Install lib32-gstreamer and lib32-gst-plugins-base if the login video or "
|
||||
"other media does not play. Missing media plugins are not evidence of an "
|
||||
"authentication failure."
|
||||
)
|
||||
return warnings
|
||||
|
||||
|
||||
def _optional_tool_status(name: str, path: str, purpose: str) -> ToolStatus:
|
||||
if _path_exists(path):
|
||||
return ToolStatus(name, path, required=False, purpose=purpose)
|
||||
return ToolStatus(name, None, required=False, purpose=purpose)
|
||||
|
||||
|
||||
def _path_exists(path: str) -> bool:
|
||||
entry = Path(path)
|
||||
return entry.is_file() or entry.is_dir()
|
||||
|
||||
@@ -23,5 +23,7 @@ def build_gamebox_command(
|
||||
]
|
||||
if settings.borderless:
|
||||
command.append("--borderless")
|
||||
if settings.suppress_unresponsive_warnings:
|
||||
command.append("--suppress-wm-ping")
|
||||
command.extend(("--", *[str(value) for value in game_command]))
|
||||
return command
|
||||
|
||||
@@ -10,8 +10,13 @@ import sys
|
||||
import time
|
||||
from collections.abc import Collection, Iterable, Sequence
|
||||
|
||||
from .desktop_identity import (
|
||||
APPLICATION_NAME,
|
||||
APPLICATION_WM_CLASS,
|
||||
APPLICATION_WM_INSTANCE,
|
||||
)
|
||||
|
||||
WINDOW_TITLE = "PristonTale EU"
|
||||
WINDOW_TITLE = APPLICATION_NAME
|
||||
WINDOW_DISCOVERY_TIMEOUT_SECONDS = 60.0
|
||||
WINDOW_READY_DELAY_SECONDS = 0.5
|
||||
_X_ERROR_HANDLER = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_void_p, ctypes.c_void_p)
|
||||
@@ -44,6 +49,14 @@ def _select_game_window(
|
||||
return None
|
||||
|
||||
|
||||
def _protocols_without(
|
||||
protocols: Sequence[int],
|
||||
blocked_protocol: int,
|
||||
) -> tuple[int, ...]:
|
||||
"""Remove only the selected atom while preserving every other protocol."""
|
||||
return tuple(protocol for protocol in protocols if protocol != blocked_protocol)
|
||||
|
||||
|
||||
class GameboxRuntimeError(RuntimeError):
|
||||
"""Raised when the game command or X11 mapper cannot be started."""
|
||||
|
||||
@@ -58,6 +71,13 @@ class _MotifWmHints(ctypes.Structure):
|
||||
]
|
||||
|
||||
|
||||
class _XClassHint(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("res_name", ctypes.c_char_p),
|
||||
("res_class", ctypes.c_char_p),
|
||||
]
|
||||
|
||||
|
||||
class _X11:
|
||||
def __init__(self) -> None:
|
||||
library_path = ctypes.util.find_library("X11") or "libX11.so.6"
|
||||
@@ -127,6 +147,85 @@ class _X11:
|
||||
self.library.XMapRaised(self.display, window)
|
||||
self.library.XFlush(self.display)
|
||||
|
||||
def set_window_identity(
|
||||
self,
|
||||
window: int,
|
||||
*,
|
||||
resource_name: str = APPLICATION_WM_INSTANCE,
|
||||
resource_class: str = APPLICATION_WM_CLASS,
|
||||
) -> None:
|
||||
"""Replace Proton's generic steamapp identity before taskbar matching."""
|
||||
|
||||
for label, value in (
|
||||
("resource name", resource_name),
|
||||
("resource class", resource_class),
|
||||
):
|
||||
if not value or "\x00" in value:
|
||||
raise GameboxRuntimeError(f"invalid X11 {label}")
|
||||
hints = _XClassHint(
|
||||
res_name=resource_name.encode("utf-8"),
|
||||
res_class=resource_class.encode("utf-8"),
|
||||
)
|
||||
if self.library.XSetClassHint(
|
||||
self.display,
|
||||
window,
|
||||
ctypes.byref(hints),
|
||||
) == 0:
|
||||
raise GameboxRuntimeError("could not set the game window identity")
|
||||
self.library.XSync(self.display, 0)
|
||||
|
||||
def suppress_wm_ping(self, window: int) -> bool:
|
||||
"""Stop the WM hang prompt for one window, preserving its other protocols."""
|
||||
ping_atom = int(self.library.XInternAtom(self.display, b"_NET_WM_PING", 0))
|
||||
if ping_atom == 0:
|
||||
return False
|
||||
protocols = self._wm_protocols(window)
|
||||
if protocols is None:
|
||||
return False
|
||||
filtered_protocols = _protocols_without(protocols, ping_atom)
|
||||
if filtered_protocols == protocols:
|
||||
return True
|
||||
if filtered_protocols:
|
||||
protocol_values = (ctypes.c_ulong * len(filtered_protocols))(
|
||||
*filtered_protocols
|
||||
)
|
||||
status = self.library.XSetWMProtocols(
|
||||
self.display,
|
||||
window,
|
||||
protocol_values,
|
||||
len(filtered_protocols),
|
||||
)
|
||||
else:
|
||||
status = self.library.XSetWMProtocols(
|
||||
self.display,
|
||||
window,
|
||||
ctypes.POINTER(ctypes.c_ulong)(),
|
||||
0,
|
||||
)
|
||||
if status == 0:
|
||||
return False
|
||||
self.library.XSync(self.display, 0)
|
||||
return self._wm_protocols(window) == filtered_protocols
|
||||
|
||||
def _wm_protocols(self, window: int) -> tuple[int, ...] | None:
|
||||
protocols = ctypes.POINTER(ctypes.c_ulong)()
|
||||
protocol_count = ctypes.c_int()
|
||||
status = self.library.XGetWMProtocols(
|
||||
self.display,
|
||||
window,
|
||||
ctypes.byref(protocols),
|
||||
ctypes.byref(protocol_count),
|
||||
)
|
||||
if status == 0:
|
||||
return None
|
||||
try:
|
||||
return tuple(
|
||||
int(protocols[index]) for index in range(protocol_count.value)
|
||||
)
|
||||
finally:
|
||||
if protocols:
|
||||
self.library.XFree(ctypes.cast(protocols, ctypes.c_void_p))
|
||||
|
||||
def _remove_decorations(self, window: int) -> None:
|
||||
atom = self.library.XInternAtom(self.display, b"_MOTIF_WM_HINTS", 0)
|
||||
if atom == 0:
|
||||
@@ -205,8 +304,30 @@ class _X11:
|
||||
ctypes.c_int,
|
||||
]
|
||||
self.library.XChangeProperty.restype = ctypes.c_int
|
||||
self.library.XSetClassHint.argtypes = [
|
||||
ctypes.c_void_p,
|
||||
ctypes.c_ulong,
|
||||
ctypes.POINTER(_XClassHint),
|
||||
]
|
||||
self.library.XSetClassHint.restype = ctypes.c_int
|
||||
self.library.XGetWMProtocols.argtypes = [
|
||||
ctypes.c_void_p,
|
||||
ctypes.c_ulong,
|
||||
ctypes.POINTER(ctypes.POINTER(ctypes.c_ulong)),
|
||||
ctypes.POINTER(ctypes.c_int),
|
||||
]
|
||||
self.library.XGetWMProtocols.restype = ctypes.c_int
|
||||
self.library.XSetWMProtocols.argtypes = [
|
||||
ctypes.c_void_p,
|
||||
ctypes.c_ulong,
|
||||
ctypes.POINTER(ctypes.c_ulong),
|
||||
ctypes.c_int,
|
||||
]
|
||||
self.library.XSetWMProtocols.restype = ctypes.c_int
|
||||
self.library.XMapRaised.argtypes = [ctypes.c_void_p, ctypes.c_ulong]
|
||||
self.library.XMapRaised.restype = ctypes.c_int
|
||||
self.library.XSync.argtypes = [ctypes.c_void_p, ctypes.c_int]
|
||||
self.library.XSync.restype = ctypes.c_int
|
||||
self.library.XFlush.argtypes = [ctypes.c_void_p]
|
||||
self.library.XFlush.restype = ctypes.c_int
|
||||
self.library.XFree.argtypes = [ctypes.c_void_p]
|
||||
@@ -215,11 +336,58 @@ class _X11:
|
||||
self.library.XCloseDisplay.restype = ctypes.c_int
|
||||
|
||||
|
||||
def _prepare_game_window(
|
||||
mapper: _X11,
|
||||
window: int,
|
||||
*,
|
||||
borderless: bool,
|
||||
suppress_wm_ping: bool,
|
||||
) -> None:
|
||||
if suppress_wm_ping:
|
||||
try:
|
||||
ping_suppressed = mapper.suppress_wm_ping(window)
|
||||
except Exception as exc:
|
||||
print(
|
||||
f"Warning: could not suppress game-window hang warnings ({exc}); "
|
||||
"continuing normally",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
else:
|
||||
if ping_suppressed:
|
||||
print(
|
||||
"Game-window hang warnings suppressed for this launch.",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
else:
|
||||
print(
|
||||
"Warning: could not verify game-window hang-warning "
|
||||
"suppression; continuing normally",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
mapper.map_window(window, borderless=borderless)
|
||||
|
||||
|
||||
def _apply_game_window_identity(mapper: _X11, window: int) -> None:
|
||||
try:
|
||||
mapper.set_window_identity(window)
|
||||
except Exception as exc:
|
||||
print(
|
||||
f"Warning: could not assign the PristonTale EU taskbar identity ({exc}); "
|
||||
"continuing normally",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
|
||||
|
||||
def run_game_window(
|
||||
command: Sequence[str],
|
||||
window_title: str = WINDOW_TITLE,
|
||||
*,
|
||||
borderless: bool = False,
|
||||
suppress_wm_ping: bool = False,
|
||||
) -> int:
|
||||
if not command:
|
||||
raise GameboxRuntimeError("game command cannot be empty")
|
||||
@@ -260,6 +428,7 @@ def run_game_window(
|
||||
game_window = window
|
||||
game_title = title
|
||||
game_window_created_at = now
|
||||
_apply_game_window_identity(mapper, window)
|
||||
print(
|
||||
f"Game window created; preparing it: {title}",
|
||||
file=sys.stderr,
|
||||
@@ -270,7 +439,12 @@ def run_game_window(
|
||||
and game_window_created_at is not None
|
||||
and now - game_window_created_at >= WINDOW_READY_DELAY_SECONDS
|
||||
):
|
||||
mapper.map_window(game_window, borderless=borderless)
|
||||
_prepare_game_window(
|
||||
mapper,
|
||||
game_window,
|
||||
borderless=borderless,
|
||||
suppress_wm_ping=suppress_wm_ping,
|
||||
)
|
||||
print(
|
||||
f"Game window ready: {game_title or window_title}",
|
||||
file=sys.stderr,
|
||||
@@ -285,7 +459,12 @@ def run_game_window(
|
||||
flush=True,
|
||||
)
|
||||
if game_window is not None:
|
||||
mapper.map_window(game_window, borderless=borderless)
|
||||
_prepare_game_window(
|
||||
mapper,
|
||||
game_window,
|
||||
borderless=borderless,
|
||||
suppress_wm_ping=suppress_wm_ping,
|
||||
)
|
||||
return int(process.wait())
|
||||
finally:
|
||||
mapper.close()
|
||||
@@ -295,6 +474,7 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--window-title", default=WINDOW_TITLE)
|
||||
parser.add_argument("--borderless", action="store_true")
|
||||
parser.add_argument("--suppress-wm-ping", action="store_true")
|
||||
parser.add_argument("command", nargs=argparse.REMAINDER)
|
||||
arguments = parser.parse_args(argv)
|
||||
command = arguments.command
|
||||
@@ -305,6 +485,7 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
command,
|
||||
arguments.window_title,
|
||||
borderless=arguments.borderless,
|
||||
suppress_wm_ping=arguments.suppress_wm_ping,
|
||||
)
|
||||
except (GameboxRuntimeError, OSError) as exc:
|
||||
print(f"Error: {exc}", file=sys.stderr)
|
||||
|
||||
393
ptlinux/identity_probe.py
Normal file
393
ptlinux/identity_probe.py
Normal file
@@ -0,0 +1,393 @@
|
||||
"""Privacy-safe checks for the Windows identity inputs used by the game client."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
from .command import CommandRunner
|
||||
from .paths import AppPaths
|
||||
from .runtime import RuntimeSelection, run_runtime_prefix_command
|
||||
|
||||
|
||||
IDENTITY_PROBE_SHA256 = "677cb5aa19f8dac65b982462bb30974feed05399fdd759765c3b6e802d4c838b"
|
||||
IDENTITY_PROBE_TIMEOUT_SECONDS = 45.0
|
||||
IDENTITY_PROBE_PRIVACY = (
|
||||
"Only success flags, row counts, value lengths, VARIANT types, adapter type, "
|
||||
"and generic-value quality flags are collected. Computer names, serial numbers, "
|
||||
"MAC addresses, and WMI values never leave the Windows helper."
|
||||
)
|
||||
WMI_PROPERTY_NAMES = (
|
||||
"processor_id",
|
||||
"processor_unique_id",
|
||||
"processor_manufacturer",
|
||||
"processor_max_clock_speed",
|
||||
"bios_release_date",
|
||||
"bios_serial_number",
|
||||
"bios_identification_code",
|
||||
"bios_smbios_version",
|
||||
"bios_manufacturer",
|
||||
"baseboard_serial_number",
|
||||
"baseboard_name",
|
||||
"baseboard_manufacturer",
|
||||
"baseboard_model",
|
||||
"video_name",
|
||||
"video_driver_version",
|
||||
)
|
||||
HRESULT_PATTERN = re.compile(r"0x[0-9A-F]{8}\Z")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IdentityProbeResult:
|
||||
helper: str
|
||||
helper_sha256: str | None
|
||||
helper_verified: bool
|
||||
ran: bool
|
||||
ready: bool | None
|
||||
error: str | None
|
||||
report: dict[str, object] | None
|
||||
observations: tuple[str, ...]
|
||||
warnings: tuple[str, ...]
|
||||
|
||||
|
||||
def packaged_identity_probe() -> Path:
|
||||
return Path(__file__).resolve().parent / "native" / "pt-identity-probe.exe"
|
||||
|
||||
|
||||
def probe_client_identity(
|
||||
paths: AppPaths,
|
||||
selection: RuntimeSelection,
|
||||
runner: CommandRunner,
|
||||
*,
|
||||
timeout_seconds: float = IDENTITY_PROBE_TIMEOUT_SECONDS,
|
||||
) -> IdentityProbeResult:
|
||||
"""Check identity API availability without returning the underlying values."""
|
||||
|
||||
helper = packaged_identity_probe()
|
||||
helper_sha256 = _sha256_if_file(helper)
|
||||
helper_verified = helper_sha256 == IDENTITY_PROBE_SHA256
|
||||
if not helper_verified:
|
||||
error = (
|
||||
"the packaged privacy-safe identity helper is missing"
|
||||
if helper_sha256 is None
|
||||
else "the packaged privacy-safe identity helper failed verification"
|
||||
)
|
||||
return IdentityProbeResult(
|
||||
helper=str(helper),
|
||||
helper_sha256=helper_sha256,
|
||||
helper_verified=False,
|
||||
ran=False,
|
||||
ready=None,
|
||||
error=error,
|
||||
report=None,
|
||||
observations=(),
|
||||
warnings=(error,),
|
||||
)
|
||||
try:
|
||||
outcome = run_runtime_prefix_command(
|
||||
paths,
|
||||
selection,
|
||||
runner,
|
||||
[str(helper)],
|
||||
check=False,
|
||||
timeout_seconds=timeout_seconds,
|
||||
)
|
||||
except (OSError, RuntimeError) as exc:
|
||||
error = f"the client-identity helper could not start: {exc}"
|
||||
return IdentityProbeResult(
|
||||
helper=str(helper),
|
||||
helper_sha256=helper_sha256,
|
||||
helper_verified=True,
|
||||
ran=False,
|
||||
ready=None,
|
||||
error=error,
|
||||
report=None,
|
||||
observations=(),
|
||||
warnings=(error,),
|
||||
)
|
||||
if outcome.timed_out:
|
||||
error = "the client-identity helper timed out"
|
||||
return _failed_result(helper, helper_sha256, error, ran=True)
|
||||
if outcome.returncode != 0:
|
||||
status = "unknown" if outcome.returncode is None else str(outcome.returncode)
|
||||
error = f"the client-identity helper exited with status {status}"
|
||||
return _failed_result(helper, helper_sha256, error, ran=True)
|
||||
try:
|
||||
report = _parse_privacy_safe_report(outcome.stdout)
|
||||
except (ValueError, json.JSONDecodeError) as exc:
|
||||
error = f"the client-identity helper returned invalid data: {exc}"
|
||||
return _failed_result(helper, helper_sha256, error, ran=True)
|
||||
warnings = _identity_warnings(report)
|
||||
return IdentityProbeResult(
|
||||
helper=str(helper),
|
||||
helper_sha256=helper_sha256,
|
||||
helper_verified=True,
|
||||
ran=True,
|
||||
ready=not warnings,
|
||||
error=None,
|
||||
report=report,
|
||||
observations=_identity_observations(report),
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
|
||||
def _failed_result(
|
||||
helper: Path,
|
||||
helper_sha256: str | None,
|
||||
error: str,
|
||||
*,
|
||||
ran: bool,
|
||||
) -> IdentityProbeResult:
|
||||
return IdentityProbeResult(
|
||||
helper=str(helper),
|
||||
helper_sha256=helper_sha256,
|
||||
helper_verified=True,
|
||||
ran=ran,
|
||||
ready=None,
|
||||
error=error,
|
||||
report=None,
|
||||
observations=(),
|
||||
warnings=(error,),
|
||||
)
|
||||
|
||||
|
||||
def _parse_privacy_safe_report(stdout: str) -> dict[str, object]:
|
||||
lines = [line.strip() for line in stdout.splitlines() if line.strip()]
|
||||
if not lines:
|
||||
raise ValueError("empty output")
|
||||
value = json.loads(lines[-1])
|
||||
root = _object(value, "root")
|
||||
if _integer(root.get("schema"), "schema") != 2:
|
||||
raise ValueError("unsupported schema")
|
||||
computer = _object(root.get("computer_name"), "computer_name")
|
||||
volume = _object(root.get("volume"), "volume")
|
||||
adapters = _object(root.get("adapters"), "adapters")
|
||||
firmware = _object(root.get("firmware"), "firmware")
|
||||
wmi = _object(root.get("wmi"), "wmi")
|
||||
properties = _object(wmi.get("properties"), "wmi.properties")
|
||||
safe_properties: dict[str, object] = {}
|
||||
for name in WMI_PROPERTY_NAMES:
|
||||
entry = _object(properties.get(name), f"wmi.properties.{name}")
|
||||
safe_properties[name] = {
|
||||
"result": _hresult(entry.get("result"), f"{name}.result"),
|
||||
"variant_type": _integer(entry.get("variant_type"), f"{name}.variant_type"),
|
||||
"rows": _integer(entry.get("rows"), f"{name}.rows"),
|
||||
"nonempty_rows": _integer(
|
||||
entry.get("nonempty_rows"),
|
||||
f"{name}.nonempty_rows",
|
||||
),
|
||||
"maximum_length": _integer(
|
||||
entry.get("maximum_length"),
|
||||
f"{name}.maximum_length",
|
||||
),
|
||||
"placeholder_rows": _integer(
|
||||
entry.get("placeholder_rows"),
|
||||
f"{name}.placeholder_rows",
|
||||
),
|
||||
"wine_rows": _integer(
|
||||
entry.get("wine_rows"),
|
||||
f"{name}.wine_rows",
|
||||
),
|
||||
}
|
||||
return {
|
||||
"schema": 2,
|
||||
"computer_name": {
|
||||
"success": _boolean(computer.get("success"), "computer_name.success"),
|
||||
"nonempty": _boolean(computer.get("nonempty"), "computer_name.nonempty"),
|
||||
"length": _integer(computer.get("length"), "computer_name.length"),
|
||||
},
|
||||
"volume": {
|
||||
"success": _boolean(volume.get("success"), "volume.success"),
|
||||
"serial_nonzero": _boolean(
|
||||
volume.get("serial_nonzero"),
|
||||
"volume.serial_nonzero",
|
||||
),
|
||||
},
|
||||
"adapters": {
|
||||
"status": _integer(adapters.get("status"), "adapters.status"),
|
||||
"count": _integer(adapters.get("count"), "adapters.count"),
|
||||
"first_address_length": _integer(
|
||||
adapters.get("first_address_length"),
|
||||
"adapters.first_address_length",
|
||||
),
|
||||
"first_address_nonzero": _boolean(
|
||||
adapters.get("first_address_nonzero"),
|
||||
"adapters.first_address_nonzero",
|
||||
),
|
||||
"first_address_locally_administered": _boolean(
|
||||
adapters.get("first_address_locally_administered"),
|
||||
"adapters.first_address_locally_administered",
|
||||
),
|
||||
"first_address_multicast": _boolean(
|
||||
adapters.get("first_address_multicast"),
|
||||
"adapters.first_address_multicast",
|
||||
),
|
||||
"first_type": _integer(adapters.get("first_type"), "adapters.first_type"),
|
||||
},
|
||||
"firmware": {
|
||||
"raw_smbios_bytes": _integer(
|
||||
firmware.get("raw_smbios_bytes"),
|
||||
"firmware.raw_smbios_bytes",
|
||||
),
|
||||
},
|
||||
"wmi": {
|
||||
"com_initialize": _hresult(wmi.get("com_initialize"), "wmi.com_initialize"),
|
||||
"com_security": _hresult(wmi.get("com_security"), "wmi.com_security"),
|
||||
"connect": _hresult(wmi.get("connect"), "wmi.connect"),
|
||||
"properties": safe_properties,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _identity_warnings(report: dict[str, object]) -> tuple[str, ...]:
|
||||
warnings: list[str] = []
|
||||
computer = _object(report["computer_name"], "computer_name")
|
||||
volume = _object(report["volume"], "volume")
|
||||
adapters = _object(report["adapters"], "adapters")
|
||||
wmi = _object(report["wmi"], "wmi")
|
||||
properties = _object(wmi["properties"], "wmi.properties")
|
||||
computer_success = _boolean(computer["success"], "computer_name.success")
|
||||
computer_nonempty = _boolean(computer["nonempty"], "computer_name.nonempty")
|
||||
volume_success = _boolean(volume["success"], "volume.success")
|
||||
volume_serial_nonzero = _boolean(volume["serial_nonzero"], "volume.serial_nonzero")
|
||||
adapter_status = _integer(adapters["status"], "adapters.status")
|
||||
adapter_count = _integer(adapters["count"], "adapters.count")
|
||||
first_address_length = _integer(
|
||||
adapters["first_address_length"],
|
||||
"adapters.first_address_length",
|
||||
)
|
||||
first_address_nonzero = _boolean(
|
||||
adapters["first_address_nonzero"],
|
||||
"adapters.first_address_nonzero",
|
||||
)
|
||||
if not computer_success or not computer_nonempty:
|
||||
warnings.append("Wine did not provide the computer name used by the login request.")
|
||||
if not volume_success or not volume_serial_nonzero:
|
||||
warnings.append("Wine did not provide a usable C: volume identity.")
|
||||
if (
|
||||
adapter_status != 0
|
||||
or adapter_count == 0
|
||||
or first_address_length < 6
|
||||
or not first_address_nonzero
|
||||
):
|
||||
warnings.append(
|
||||
"Wine did not provide a usable first network-adapter identity; the game "
|
||||
"would send its UUID fallback instead."
|
||||
)
|
||||
if _hresult(wmi["connect"], "wmi.connect") != "0x00000000":
|
||||
warnings.append("Wine could not connect to ROOT\\CIMV2 for the login identity query.")
|
||||
else:
|
||||
if not _any_nonempty(properties, ("processor_id", "processor_unique_id")):
|
||||
warnings.append("Wine returned no processor identifier through WMI.")
|
||||
if not _any_nonempty(
|
||||
properties,
|
||||
(
|
||||
"bios_release_date",
|
||||
"bios_serial_number",
|
||||
"bios_identification_code",
|
||||
"bios_smbios_version",
|
||||
"bios_manufacturer",
|
||||
),
|
||||
):
|
||||
warnings.append("Wine returned no BIOS identity fields through WMI.")
|
||||
if not _any_nonempty(
|
||||
properties,
|
||||
(
|
||||
"baseboard_serial_number",
|
||||
"baseboard_name",
|
||||
"baseboard_manufacturer",
|
||||
"baseboard_model",
|
||||
),
|
||||
):
|
||||
warnings.append("Wine returned no baseboard identity fields through WMI.")
|
||||
if not _any_nonempty(properties, ("video_name",)):
|
||||
warnings.append("Wine returned no video-controller name through WMI.")
|
||||
return tuple(warnings)
|
||||
|
||||
|
||||
def _identity_observations(report: dict[str, object]) -> tuple[str, ...]:
|
||||
observations: list[str] = []
|
||||
adapters = _object(report["adapters"], "adapters")
|
||||
if _boolean(
|
||||
adapters["first_address_locally_administered"],
|
||||
"adapters.first_address_locally_administered",
|
||||
):
|
||||
observations.append(
|
||||
"The first network-adapter address is locally administered."
|
||||
)
|
||||
if _boolean(
|
||||
adapters["first_address_multicast"],
|
||||
"adapters.first_address_multicast",
|
||||
):
|
||||
observations.append("The first network-adapter address has the multicast bit set.")
|
||||
wmi = _object(report["wmi"], "wmi")
|
||||
properties = _object(wmi["properties"], "wmi.properties")
|
||||
placeholder_names: list[str] = []
|
||||
wine_names: list[str] = []
|
||||
for name in WMI_PROPERTY_NAMES:
|
||||
entry = _object(properties[name], name)
|
||||
if _integer(entry["placeholder_rows"], f"{name}.placeholder_rows") > 0:
|
||||
placeholder_names.append(name)
|
||||
if _integer(entry["wine_rows"], f"{name}.wine_rows") > 0:
|
||||
wine_names.append(name)
|
||||
if placeholder_names:
|
||||
observations.append(
|
||||
"Common placeholder WMI values were observed in: "
|
||||
+ ", ".join(placeholder_names)
|
||||
)
|
||||
if wine_names:
|
||||
observations.append(
|
||||
"Wine-labelled WMI values were observed in: " + ", ".join(wine_names)
|
||||
)
|
||||
return tuple(observations)
|
||||
|
||||
|
||||
def _any_nonempty(properties: dict[str, object], names: tuple[str, ...]) -> bool:
|
||||
for name in names:
|
||||
entry = _object(properties[name], name)
|
||||
if (
|
||||
_hresult(entry["result"], f"{name}.result") == "0x00000000"
|
||||
and _integer(entry["nonempty_rows"], f"{name}.nonempty_rows") > 0
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _object(value: object, label: str) -> dict[str, object]:
|
||||
if not isinstance(value, dict) or not all(isinstance(key, str) for key in value):
|
||||
raise ValueError(f"{label} must be an object")
|
||||
return value
|
||||
|
||||
|
||||
def _boolean(value: object, label: str) -> bool:
|
||||
if not isinstance(value, bool):
|
||||
raise ValueError(f"{label} must be a boolean")
|
||||
return value
|
||||
|
||||
|
||||
def _integer(value: object, label: str) -> int:
|
||||
if isinstance(value, bool) or not isinstance(value, int) or value < 0 or value > 0xFFFFFFFF:
|
||||
raise ValueError(f"{label} must be an unsigned 32-bit integer")
|
||||
return value
|
||||
|
||||
|
||||
def _hresult(value: object, label: str) -> str:
|
||||
if not isinstance(value, str) or HRESULT_PATTERN.fullmatch(value) is None:
|
||||
raise ValueError(f"{label} must be a hexadecimal HRESULT")
|
||||
return value
|
||||
|
||||
|
||||
def _sha256_if_file(path: Path) -> str | None:
|
||||
try:
|
||||
if not path.is_file():
|
||||
return None
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
except OSError:
|
||||
return None
|
||||
85
ptlinux/import_state.py
Normal file
85
ptlinux/import_state.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""Persistent trust state for game trees seeded from another installation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
from .paths import AppPaths
|
||||
|
||||
|
||||
IMPORT_STATE_SCHEMA = 2
|
||||
|
||||
|
||||
def game_import_requires_verification(paths: AppPaths) -> bool:
|
||||
"""Return whether a managed imported tree still needs a full depot repair."""
|
||||
|
||||
manifest = paths.game_import_manifest
|
||||
if not manifest.exists():
|
||||
return False
|
||||
try:
|
||||
value = json.loads(manifest.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeError, json.JSONDecodeError):
|
||||
return True
|
||||
if not isinstance(value, dict):
|
||||
return True
|
||||
build = value.get("verification_build")
|
||||
completed_at = value.get("verification_completed_at")
|
||||
return not (
|
||||
value.get("verification_required") is False
|
||||
and isinstance(build, int)
|
||||
and not isinstance(build, bool)
|
||||
and build > 0
|
||||
and isinstance(completed_at, str)
|
||||
and bool(completed_at.strip())
|
||||
)
|
||||
|
||||
|
||||
def mark_game_import_verified(
|
||||
paths: AppPaths,
|
||||
*,
|
||||
build: int,
|
||||
files_quarantined: int,
|
||||
quarantine_directory: str | None,
|
||||
) -> None:
|
||||
"""Record a completed full-manifest verification of an imported tree."""
|
||||
|
||||
if isinstance(build, bool) or build <= 0:
|
||||
raise ValueError("verified import build must be positive")
|
||||
if files_quarantined < 0:
|
||||
raise ValueError("quarantined file count cannot be negative")
|
||||
try:
|
||||
value = json.loads(paths.game_import_manifest.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeError, json.JSONDecodeError):
|
||||
value = {}
|
||||
if not isinstance(value, dict):
|
||||
value = {}
|
||||
value.update(
|
||||
{
|
||||
"schema": IMPORT_STATE_SCHEMA,
|
||||
"verification_required": False,
|
||||
"verification_build": build,
|
||||
"verification_completed_at": datetime.now().astimezone().isoformat(),
|
||||
"files_quarantined": files_quarantined,
|
||||
"quarantine_directory": quarantine_directory,
|
||||
}
|
||||
)
|
||||
_write_json(paths.game_import_manifest, value)
|
||||
|
||||
|
||||
def _write_json(path: Path, value: object) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = path.with_name(f".{path.name}.{os.getpid()}.{uuid4().hex}.tmp")
|
||||
try:
|
||||
with temporary.open("x", encoding="utf-8") as handle:
|
||||
json.dump(value, handle, indent=2, sort_keys=True)
|
||||
handle.write("\n")
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
temporary.replace(path)
|
||||
except BaseException:
|
||||
temporary.unlink(missing_ok=True)
|
||||
raise
|
||||
@@ -9,6 +9,7 @@ import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import stat
|
||||
from typing import Mapping
|
||||
import uuid
|
||||
|
||||
@@ -43,6 +44,7 @@ class GameImportResult:
|
||||
installed_build: int | None
|
||||
started_at: str
|
||||
completed_at: str
|
||||
verification_required: bool
|
||||
|
||||
|
||||
def import_existing_game(
|
||||
@@ -87,10 +89,13 @@ def import_existing_game(
|
||||
),
|
||||
started_at=started_at,
|
||||
completed_at=started_at,
|
||||
verification_required=True,
|
||||
)
|
||||
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = destination.with_name(f".{destination.name}.import-{uuid.uuid4().hex}")
|
||||
committed = False
|
||||
pending_recorded = False
|
||||
try:
|
||||
temporary.mkdir(parents=False, exist_ok=False)
|
||||
runner.run(
|
||||
@@ -111,8 +116,31 @@ def import_existing_game(
|
||||
):
|
||||
cache_compatibility_binaries(paths, source)
|
||||
temporary.replace(destination)
|
||||
committed = True
|
||||
provisional = GameImportResult(
|
||||
source=str(source),
|
||||
destination=str(destination),
|
||||
copy_mode="reflink-or-copy",
|
||||
files=source_before.files,
|
||||
bytes=source_before.bytes,
|
||||
core_sha256=source_before.core_sha256,
|
||||
depot_metadata_source=(
|
||||
str(depot_metadata.path) if depot_metadata is not None else None
|
||||
),
|
||||
depot_metadata_destination=None,
|
||||
installed_build=(
|
||||
depot_metadata.installed_build if depot_metadata is not None else None
|
||||
),
|
||||
started_at=started_at,
|
||||
completed_at=datetime.now().astimezone().isoformat(),
|
||||
verification_required=True,
|
||||
)
|
||||
_write_import_manifest(paths, provisional)
|
||||
pending_recorded = True
|
||||
except BaseException:
|
||||
_remove_staging_tree(temporary, destination.parent)
|
||||
if committed and not pending_recorded:
|
||||
_remove_staging_tree(destination, destination.parent)
|
||||
raise
|
||||
|
||||
metadata_destination = None
|
||||
@@ -140,8 +168,9 @@ def import_existing_game(
|
||||
),
|
||||
started_at=started_at,
|
||||
completed_at=datetime.now().astimezone().isoformat(),
|
||||
verification_required=True,
|
||||
)
|
||||
_write_json(paths.game_import_manifest, asdict(result))
|
||||
_write_import_manifest(paths, result)
|
||||
return result
|
||||
|
||||
|
||||
@@ -202,22 +231,48 @@ def inventory_game_tree(source_game: Path) -> GameTreeInventory:
|
||||
source = source_game.expanduser().resolve(strict=True)
|
||||
for relative in REQUIRED_GAME_FILES:
|
||||
path = source / relative
|
||||
if not path.is_file():
|
||||
try:
|
||||
status = path.lstat()
|
||||
except OSError:
|
||||
raise GameImportError(f"game installation is incomplete: missing {relative}")
|
||||
if stat.S_ISLNK(status.st_mode):
|
||||
raise GameImportError(f"game installation contains a symbolic link: {relative}")
|
||||
if not stat.S_ISREG(status.st_mode):
|
||||
raise GameImportError(f"game installation contains a non-file: {relative}")
|
||||
|
||||
files = 0
|
||||
total_bytes = 0
|
||||
for root, _directories, filenames in os.walk(source, followlinks=False):
|
||||
|
||||
def walk_error(error: OSError) -> None:
|
||||
raise GameImportError(f"cannot inventory game tree: {error}") from error
|
||||
|
||||
for root, directories, filenames in os.walk(
|
||||
source,
|
||||
followlinks=False,
|
||||
onerror=walk_error,
|
||||
):
|
||||
root_path = Path(root)
|
||||
for directory in directories:
|
||||
path = root_path / directory
|
||||
try:
|
||||
status = path.lstat()
|
||||
except OSError as exc:
|
||||
raise GameImportError(f"cannot inventory game directory {path}: {exc}") from exc
|
||||
if stat.S_ISLNK(status.st_mode):
|
||||
raise GameImportError(f"game installation contains a symbolic link: {path}")
|
||||
if not stat.S_ISDIR(status.st_mode):
|
||||
raise GameImportError(f"game installation contains a special entry: {path}")
|
||||
for filename in filenames:
|
||||
path = root_path / filename
|
||||
if path.is_symlink():
|
||||
files += 1
|
||||
continue
|
||||
try:
|
||||
total_bytes += path.stat().st_size
|
||||
status = path.lstat()
|
||||
except OSError as exc:
|
||||
raise GameImportError(f"cannot inventory game file {path}: {exc}") from exc
|
||||
if stat.S_ISLNK(status.st_mode):
|
||||
raise GameImportError(f"game installation contains a symbolic link: {path}")
|
||||
if not stat.S_ISREG(status.st_mode):
|
||||
raise GameImportError(f"game installation contains a special entry: {path}")
|
||||
total_bytes += status.st_size
|
||||
files += 1
|
||||
return GameTreeInventory(
|
||||
files=files,
|
||||
@@ -327,6 +382,21 @@ def _sha256(path: Path) -> str:
|
||||
|
||||
def _write_json(path: Path, value: object) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
||||
temporary.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
temporary.replace(path)
|
||||
temporary = path.with_name(f".{path.name}.{os.getpid()}.{uuid.uuid4().hex}.tmp")
|
||||
try:
|
||||
with temporary.open("x", encoding="utf-8") as handle:
|
||||
json.dump(value, handle, indent=2, sort_keys=True)
|
||||
handle.write("\n")
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
temporary.replace(path)
|
||||
except BaseException:
|
||||
temporary.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
|
||||
def _write_import_manifest(paths: AppPaths, result: GameImportResult) -> None:
|
||||
_write_json(
|
||||
paths.game_import_manifest,
|
||||
{"schema": 2, **asdict(result)},
|
||||
)
|
||||
|
||||
@@ -7,6 +7,7 @@ from enum import Enum
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from .import_state import game_import_requires_verification
|
||||
from .paths import AppPaths
|
||||
|
||||
|
||||
@@ -24,6 +25,8 @@ class PrimaryAction(str, Enum):
|
||||
INSTALL = "INSTALL"
|
||||
UPDATE = "UPDATE"
|
||||
PLAY = "PLAY"
|
||||
CHECKING = "CHECKING…"
|
||||
LAUNCHER_UPDATE = "UPDATE LAUNCHER"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -40,8 +43,10 @@ class GameInstallationStatus:
|
||||
def inspect_game_installation(
|
||||
paths: AppPaths,
|
||||
official_build: int | None = None,
|
||||
*,
|
||||
compatibility_ready: bool | None = True,
|
||||
) -> GameInstallationStatus:
|
||||
"""Select Install, Update, or Play without modifying the installation.
|
||||
"""Select the primary launcher action without modifying the installation.
|
||||
|
||||
A completed native sync is the verified source for the local depot build.
|
||||
When the official build cannot be checked, a complete local game remains
|
||||
@@ -54,6 +59,8 @@ def inspect_game_installation(
|
||||
or official_build < 0
|
||||
):
|
||||
raise ValueError("official build must be a non-negative integer")
|
||||
if compatibility_ready is not None and not isinstance(compatibility_ready, bool):
|
||||
raise ValueError("compatibility readiness must be a boolean or null")
|
||||
|
||||
missing_files = tuple(
|
||||
relative
|
||||
@@ -64,10 +71,16 @@ def inspect_game_installation(
|
||||
local_build = _read_verified_local_build(paths.native_depot_state)
|
||||
if not installed:
|
||||
action = PrimaryAction.INSTALL
|
||||
elif game_import_requires_verification(paths):
|
||||
action = PrimaryAction.UPDATE
|
||||
elif official_build is not None and (
|
||||
local_build is None or local_build < official_build
|
||||
):
|
||||
action = PrimaryAction.UPDATE
|
||||
elif compatibility_ready is None:
|
||||
action = PrimaryAction.CHECKING
|
||||
elif not compatibility_ready:
|
||||
action = PrimaryAction.LAUNCHER_UPDATE
|
||||
else:
|
||||
action = PrimaryAction.PLAY
|
||||
return GameInstallationStatus(
|
||||
|
||||
@@ -7,13 +7,51 @@ from datetime import datetime
|
||||
import fcntl
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Iterator
|
||||
from typing import Iterator, TextIO
|
||||
|
||||
|
||||
class OperationBusyError(RuntimeError):
|
||||
"""Raised when another launcher-prefix operation is still active."""
|
||||
|
||||
|
||||
class LauncherPlayLease:
|
||||
"""Exclusive preparation lease that can become a shared game lease."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
handle: TextIO | None,
|
||||
*,
|
||||
preparation_allowed: bool,
|
||||
record_owned: bool,
|
||||
) -> None:
|
||||
self._handle = handle
|
||||
self.preparation_allowed = preparation_allowed
|
||||
self._record_owned = record_owned
|
||||
self._shared = not preparation_allowed
|
||||
|
||||
@classmethod
|
||||
def dry_run(cls) -> LauncherPlayLease:
|
||||
return cls(None, preparation_allowed=True, record_owned=False)
|
||||
|
||||
@property
|
||||
def shared(self) -> bool:
|
||||
return self._shared
|
||||
|
||||
def allow_shared_clients(self) -> None:
|
||||
"""Downgrade after all runtime mutations have completed."""
|
||||
|
||||
if self._shared:
|
||||
return
|
||||
if self._handle is not None:
|
||||
_write_owner(self._handle, "play")
|
||||
fcntl.flock(self._handle.fileno(), fcntl.LOCK_SH)
|
||||
self._shared = True
|
||||
|
||||
@property
|
||||
def record_owned(self) -> bool:
|
||||
return self._record_owned
|
||||
|
||||
|
||||
@contextmanager
|
||||
def launcher_operation_lock(lock_path: Path, operation: str) -> Iterator[None]:
|
||||
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -24,15 +62,9 @@ def launcher_operation_lock(lock_path: Path, operation: str) -> Iterator[None]:
|
||||
handle.seek(0)
|
||||
owner = handle.read().strip() or "unknown operation"
|
||||
raise OperationBusyError(
|
||||
f"launcher files are busy ({owner}); close the updater and try again"
|
||||
f"launcher files are busy ({owner}); wait for it to finish and try again"
|
||||
) from exc
|
||||
handle.seek(0)
|
||||
handle.truncate()
|
||||
handle.write(
|
||||
f"{operation}, pid={os.getpid()}, "
|
||||
f"started={datetime.now().astimezone().isoformat()}\n"
|
||||
)
|
||||
handle.flush()
|
||||
_write_owner(handle, operation)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
@@ -40,3 +72,67 @@ def launcher_operation_lock(lock_path: Path, operation: str) -> Iterator[None]:
|
||||
handle.truncate()
|
||||
handle.flush()
|
||||
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def launcher_play_lock(
|
||||
lock_path: Path,
|
||||
operation: str = "play",
|
||||
) -> Iterator[LauncherPlayLease]:
|
||||
"""Serialize preparation, then allow multiple active game clients.
|
||||
|
||||
The first play process acquires an exclusive lease so it may safely rebuild
|
||||
the runtime view or prefix. It downgrades that lease immediately before
|
||||
starting Game.exe. Later play processes then receive shared, read-only leases,
|
||||
while updates and repairs remain excluded until every client exits.
|
||||
"""
|
||||
|
||||
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with lock_path.open("a+", encoding="utf-8") as handle:
|
||||
try:
|
||||
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError:
|
||||
try:
|
||||
fcntl.flock(handle.fileno(), fcntl.LOCK_SH | fcntl.LOCK_NB)
|
||||
except BlockingIOError as exc:
|
||||
owner = _read_owner(handle)
|
||||
raise OperationBusyError(
|
||||
f"launcher files are busy ({owner}); wait for it to finish and try again"
|
||||
) from exc
|
||||
lease = LauncherPlayLease(
|
||||
handle,
|
||||
preparation_allowed=False,
|
||||
record_owned=False,
|
||||
)
|
||||
else:
|
||||
_write_owner(handle, f"{operation} preparation")
|
||||
lease = LauncherPlayLease(
|
||||
handle,
|
||||
preparation_allowed=True,
|
||||
record_owned=True,
|
||||
)
|
||||
try:
|
||||
yield lease
|
||||
finally:
|
||||
# A downgraded owner record remains useful while any secondary shared
|
||||
# lease is active. The next exclusive operation atomically replaces it.
|
||||
if lease.record_owned and not lease.shared:
|
||||
handle.seek(0)
|
||||
handle.truncate()
|
||||
handle.flush()
|
||||
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
||||
|
||||
|
||||
def _read_owner(handle: TextIO) -> str:
|
||||
handle.seek(0)
|
||||
return handle.read().strip() or "unknown operation"
|
||||
|
||||
|
||||
def _write_owner(handle: TextIO, operation: str) -> None:
|
||||
handle.seek(0)
|
||||
handle.truncate()
|
||||
handle.write(
|
||||
f"{operation}, pid={os.getpid()}, "
|
||||
f"started={datetime.now().astimezone().isoformat()}\n"
|
||||
)
|
||||
handle.flush()
|
||||
|
||||
601
ptlinux/login_diagnostics.py
Normal file
601
ptlinux/login_diagnostics.py
Normal file
@@ -0,0 +1,601 @@
|
||||
"""Credential-free diagnostics for the PristonTale EU login path."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, replace
|
||||
from datetime import datetime
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import socket
|
||||
import sys
|
||||
import time
|
||||
from typing import Callable, Protocol
|
||||
|
||||
from . import __version__
|
||||
from .command import CommandRunner
|
||||
from .identity_probe import (
|
||||
IDENTITY_PROBE_PRIVACY,
|
||||
IdentityProbeResult,
|
||||
probe_client_identity,
|
||||
)
|
||||
from .login_trace import LOGIN_TRACE_DISABLED_REASON, LOGIN_TRACE_ENABLED
|
||||
from .overlay import (
|
||||
COMPATIBILITY_BINARY_HASHES,
|
||||
CURRENT_V21_BINARY_HASHES,
|
||||
PATCHED_V21_GAME_DLL_SHA256,
|
||||
RETIRED_POST_AUTH_GAME_DLL_SHA256,
|
||||
)
|
||||
from .paths import AppPaths
|
||||
from .runtime import (
|
||||
RuntimeSelection,
|
||||
detect_distribution_id,
|
||||
native_d3d_ready,
|
||||
resolve_proton_sync_mode,
|
||||
select_runtime,
|
||||
)
|
||||
from .settings import Settings
|
||||
|
||||
|
||||
LOGIN_SERVER_HOST = "5.135.35.188"
|
||||
LOGIN_SERVER_PORT = 10019
|
||||
DEFAULT_LOGIN_TIMEOUT_SECONDS = 5.0
|
||||
VERIFIED_GAME_PROFILES = frozenset(
|
||||
{"v21.75.83", "verified-compatibility"}
|
||||
)
|
||||
SERVER_LOGIN_RESPONSE_OWNERSHIP = (
|
||||
"Banned Code 1 and Banned Code 3 are server-provided login response text; "
|
||||
"neither string exists in the verified V21.75.83 Game.exe/game.dll pair"
|
||||
)
|
||||
CLIENT_IDENTITY_INPUTS = (
|
||||
"Win32_Processor",
|
||||
"Win32_BIOS",
|
||||
"Win32_BaseBoard",
|
||||
"Win32_VideoController",
|
||||
"network-adapter identity",
|
||||
"C: volume information",
|
||||
"computer name",
|
||||
)
|
||||
PUBLISHER_COMPARISON_COMMAND = "pristontale-eu wine-update"
|
||||
RUNTIME_COMPONENT_PRIVACY = (
|
||||
"Only SHA-256 hashes of public runtime executables and DLLs plus expected DLL-"
|
||||
"override flags are reported. Registry contents and machine identifiers are not "
|
||||
"hashed or returned."
|
||||
)
|
||||
PREFIX_COMPONENT_PATHS = (
|
||||
"drive_c/windows/syswow64/d3d9.dll",
|
||||
"drive_c/windows/syswow64/d3dx9_43.dll",
|
||||
"drive_c/windows/syswow64/d3dcompiler_43.dll",
|
||||
"drive_c/windows/syswow64/iphlpapi.dll",
|
||||
"drive_c/windows/syswow64/kernel32.dll",
|
||||
"drive_c/windows/syswow64/ole32.dll",
|
||||
"drive_c/windows/syswow64/oleaut32.dll",
|
||||
"drive_c/windows/syswow64/wbemprox.dll",
|
||||
"drive_c/windows/syswow64/wbem/wbemprox.dll",
|
||||
)
|
||||
|
||||
|
||||
class _Closable(Protocol):
|
||||
def close(self) -> None: ...
|
||||
|
||||
|
||||
LoginConnector = Callable[[tuple[str, int], float], _Closable]
|
||||
|
||||
|
||||
def _create_login_connection(address: tuple[str, int], timeout: float) -> _Closable:
|
||||
return socket.create_connection(address, timeout)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LoginServerProbe:
|
||||
host: str
|
||||
port: int
|
||||
reachable: bool
|
||||
latency_ms: float | None
|
||||
error: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RuntimeComponentFingerprint:
|
||||
privacy: str
|
||||
runtime_wrapper_sha256: str | None
|
||||
proton_script_sha256: str | None
|
||||
proton_version_sha256: str | None
|
||||
proton_toolmanifest_sha256: str | None
|
||||
proton_wine_sha256: str | None
|
||||
proton_wineserver_sha256: str | None
|
||||
prefix_dll_sha256: dict[str, str | None]
|
||||
registry_overrides: dict[str, bool]
|
||||
runtime_marker_matches_selection: bool | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LoginDiagnosticReport:
|
||||
preflight_ok: bool
|
||||
checked_at: str
|
||||
launcher_version: str
|
||||
launcher_module: str
|
||||
python_executable: str
|
||||
distribution_id: str
|
||||
kernel_release: str
|
||||
login_server: LoginServerProbe
|
||||
authentication_tested: bool
|
||||
login_response_ownership: str
|
||||
client_identity_inputs: tuple[str, ...]
|
||||
client_identity_values_collected: bool
|
||||
client_identity_shape_collected: bool
|
||||
client_identity_ready: bool | None
|
||||
client_identity_privacy: str
|
||||
client_identity_probe: IdentityProbeResult | None
|
||||
login_trace_enabled: bool
|
||||
login_trace_disabled_reason: str | None
|
||||
publisher_comparison_command: str
|
||||
install_directory: str
|
||||
game_exe: str
|
||||
game_exe_sha256: str | None
|
||||
game_dll: str
|
||||
game_dll_sha256: str | None
|
||||
source_game_exe_sha256: str | None
|
||||
source_game_dll_sha256: str | None
|
||||
runtime_matches_managed_source: bool
|
||||
game_profile: str
|
||||
depot_build: int | None
|
||||
depot_version: str | None
|
||||
depot_completed_at: str | None
|
||||
runtime_configured: str
|
||||
runtime_requested: str
|
||||
runtime_override_applied: bool
|
||||
runtime_kind: str | None
|
||||
runtime_path: str | None
|
||||
runtime_error: str | None
|
||||
runtime_components: RuntimeComponentFingerprint | None
|
||||
proton_sync_configured: str
|
||||
proton_sync_effective: str | None
|
||||
prefix: str | None
|
||||
prefix_ready: bool
|
||||
directx_ready: bool
|
||||
latest_game_log: str | None
|
||||
latest_game_log_modified_at: str | None
|
||||
warnings: tuple[str, ...]
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
def probe_login_server(
|
||||
*,
|
||||
host: str = LOGIN_SERVER_HOST,
|
||||
port: int = LOGIN_SERVER_PORT,
|
||||
timeout_seconds: float = DEFAULT_LOGIN_TIMEOUT_SECONDS,
|
||||
connector: LoginConnector = _create_login_connection,
|
||||
clock: Callable[[], float] = time.monotonic,
|
||||
) -> LoginServerProbe:
|
||||
"""Test only the TCP handshake; never send credentials or protocol data."""
|
||||
|
||||
if timeout_seconds <= 0:
|
||||
raise ValueError("login diagnostic timeout must be greater than zero")
|
||||
started = clock()
|
||||
try:
|
||||
connection = connector((host, port), timeout_seconds)
|
||||
connection.close()
|
||||
except OSError as exc:
|
||||
return LoginServerProbe(
|
||||
host=host,
|
||||
port=port,
|
||||
reachable=False,
|
||||
latency_ms=None,
|
||||
error=f"{type(exc).__name__}: {exc}",
|
||||
)
|
||||
return LoginServerProbe(
|
||||
host=host,
|
||||
port=port,
|
||||
reachable=True,
|
||||
latency_ms=round((clock() - started) * 1000.0, 1),
|
||||
error=None,
|
||||
)
|
||||
|
||||
|
||||
def inspect_login_environment(
|
||||
paths: AppPaths,
|
||||
settings: Settings,
|
||||
*,
|
||||
timeout_seconds: float = DEFAULT_LOGIN_TIMEOUT_SECONDS,
|
||||
connector: LoginConnector = _create_login_connection,
|
||||
runner: CommandRunner | None = None,
|
||||
runtime_override: str | None = None,
|
||||
) -> LoginDiagnosticReport:
|
||||
"""Inspect the installed client and its network route without authenticating."""
|
||||
|
||||
probe = probe_login_server(
|
||||
timeout_seconds=timeout_seconds,
|
||||
connector=connector,
|
||||
)
|
||||
runtime_requested = runtime_override or settings.runtime
|
||||
diagnostic_settings = replace(settings, runtime=runtime_requested)
|
||||
selection, runtime_error = _runtime_selection(diagnostic_settings)
|
||||
distribution_id = detect_distribution_id() or "unknown"
|
||||
kernel_release = platform.release()
|
||||
proton_sync_effective = (
|
||||
resolve_proton_sync_mode(
|
||||
settings.proton_sync,
|
||||
distribution_id=distribution_id,
|
||||
kernel_release=kernel_release,
|
||||
)
|
||||
if selection is not None
|
||||
and selection.kind in {"proton7", "proton7-container"}
|
||||
else None
|
||||
)
|
||||
game_root = _active_game_root(paths)
|
||||
game_exe = game_root / "Game.exe"
|
||||
game_dll = game_root / "game.dll"
|
||||
game_exe_sha256 = _sha256_if_file(game_exe)
|
||||
game_dll_sha256 = _sha256_if_file(game_dll)
|
||||
source_game_exe_sha256 = _sha256_if_file(paths.source_game / "Game.exe")
|
||||
source_game_dll_sha256 = _sha256_if_file(paths.source_game / "game.dll")
|
||||
runtime_matches_managed_source = (
|
||||
game_exe_sha256 is not None
|
||||
and game_dll_sha256 is not None
|
||||
and game_exe_sha256 == source_game_exe_sha256
|
||||
and game_dll_sha256 == source_game_dll_sha256
|
||||
)
|
||||
game_profile = classify_game_profile(game_exe_sha256, game_dll_sha256)
|
||||
depot_state = _read_json_object(paths.native_depot_state)
|
||||
prefix = _runtime_prefix(paths, selection)
|
||||
prefix_ready = _prefix_ready(prefix)
|
||||
directx_ready = native_d3d_ready(prefix) if prefix is not None else False
|
||||
runtime_components = (
|
||||
inspect_runtime_components(paths, selection, prefix)
|
||||
if selection is not None and prefix is not None
|
||||
else None
|
||||
)
|
||||
identity_probe = (
|
||||
probe_client_identity(paths, selection, runner)
|
||||
if selection is not None and prefix_ready and runner is not None
|
||||
else None
|
||||
)
|
||||
latest_log = latest_native_game_log(paths)
|
||||
warnings = _diagnostic_warnings(
|
||||
probe,
|
||||
game_profile,
|
||||
runtime_error,
|
||||
prefix_ready,
|
||||
directx_ready,
|
||||
latest_log,
|
||||
runtime_matches_managed_source,
|
||||
identity_probe,
|
||||
)
|
||||
identity_preflight_ok = (
|
||||
True
|
||||
if runner is None
|
||||
else identity_probe is not None and identity_probe.ready is True
|
||||
)
|
||||
return LoginDiagnosticReport(
|
||||
preflight_ok=(
|
||||
probe.reachable
|
||||
and game_profile in VERIFIED_GAME_PROFILES
|
||||
and runtime_matches_managed_source
|
||||
and selection is not None
|
||||
and prefix_ready
|
||||
and directx_ready
|
||||
and identity_preflight_ok
|
||||
),
|
||||
checked_at=datetime.now().astimezone().isoformat(),
|
||||
launcher_version=__version__,
|
||||
launcher_module=str(Path(__file__).resolve()),
|
||||
python_executable=sys.executable,
|
||||
distribution_id=distribution_id,
|
||||
kernel_release=kernel_release,
|
||||
login_server=probe,
|
||||
authentication_tested=False,
|
||||
login_response_ownership=SERVER_LOGIN_RESPONSE_OWNERSHIP,
|
||||
client_identity_inputs=CLIENT_IDENTITY_INPUTS,
|
||||
client_identity_values_collected=False,
|
||||
client_identity_shape_collected=(
|
||||
identity_probe is not None and identity_probe.report is not None
|
||||
),
|
||||
client_identity_ready=(
|
||||
identity_probe.ready if identity_probe is not None else None
|
||||
),
|
||||
client_identity_privacy=IDENTITY_PROBE_PRIVACY,
|
||||
client_identity_probe=identity_probe,
|
||||
login_trace_enabled=LOGIN_TRACE_ENABLED,
|
||||
login_trace_disabled_reason=(
|
||||
None if LOGIN_TRACE_ENABLED else LOGIN_TRACE_DISABLED_REASON
|
||||
),
|
||||
publisher_comparison_command=PUBLISHER_COMPARISON_COMMAND,
|
||||
install_directory=str(paths.data),
|
||||
game_exe=str(game_exe),
|
||||
game_exe_sha256=game_exe_sha256,
|
||||
game_dll=str(game_dll),
|
||||
game_dll_sha256=game_dll_sha256,
|
||||
source_game_exe_sha256=source_game_exe_sha256,
|
||||
source_game_dll_sha256=source_game_dll_sha256,
|
||||
runtime_matches_managed_source=runtime_matches_managed_source,
|
||||
game_profile=game_profile,
|
||||
depot_build=_integer_or_none(depot_state.get("build")),
|
||||
depot_version=_string_or_none(depot_state.get("version")),
|
||||
depot_completed_at=_string_or_none(depot_state.get("completed_at")),
|
||||
runtime_configured=settings.runtime,
|
||||
runtime_requested=runtime_requested,
|
||||
runtime_override_applied=runtime_override is not None,
|
||||
runtime_kind=selection.kind if selection is not None else None,
|
||||
runtime_path=_runtime_path(selection),
|
||||
runtime_error=runtime_error,
|
||||
runtime_components=runtime_components,
|
||||
proton_sync_configured=settings.proton_sync,
|
||||
proton_sync_effective=proton_sync_effective,
|
||||
prefix=str(prefix) if prefix is not None else None,
|
||||
prefix_ready=prefix_ready,
|
||||
directx_ready=directx_ready,
|
||||
latest_game_log=str(latest_log) if latest_log is not None else None,
|
||||
latest_game_log_modified_at=_modified_at(latest_log),
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
|
||||
def inspect_runtime_components(
|
||||
paths: AppPaths,
|
||||
selection: RuntimeSelection,
|
||||
prefix: Path,
|
||||
) -> RuntimeComponentFingerprint:
|
||||
"""Fingerprint non-private runtime components for side-by-side reports."""
|
||||
|
||||
proton_script_sha256: str | None = None
|
||||
proton_version_sha256: str | None = None
|
||||
proton_toolmanifest_sha256: str | None = None
|
||||
proton_wine_sha256: str | None = None
|
||||
proton_wineserver_sha256: str | None = None
|
||||
if selection.proton_path:
|
||||
proton = Path(selection.proton_path)
|
||||
proton_script_sha256 = _sha256_if_file(proton)
|
||||
proton_version_sha256 = _sha256_if_file(proton.parent / "version")
|
||||
proton_toolmanifest_sha256 = _sha256_if_file(
|
||||
proton.parent / "toolmanifest.vdf"
|
||||
)
|
||||
for directory in (proton.parent / "files/bin", proton.parent / "dist/bin"):
|
||||
wine = directory / "wine"
|
||||
wineserver = directory / "wineserver"
|
||||
if wine.is_file() and wineserver.is_file():
|
||||
proton_wine_sha256 = _sha256_if_file(wine)
|
||||
proton_wineserver_sha256 = _sha256_if_file(wineserver)
|
||||
break
|
||||
prefix_dll_sha256 = {
|
||||
relative: _sha256_if_file(prefix / relative)
|
||||
for relative in PREFIX_COMPONENT_PATHS
|
||||
}
|
||||
runtime_marker = _read_json_object(paths.game_runtime_marker)
|
||||
marker_matches = (
|
||||
runtime_marker.get("runtime") == asdict(selection)
|
||||
if runtime_marker
|
||||
else None
|
||||
)
|
||||
return RuntimeComponentFingerprint(
|
||||
privacy=RUNTIME_COMPONENT_PRIVACY,
|
||||
runtime_wrapper_sha256=_sha256_if_file(Path(selection.executable)),
|
||||
proton_script_sha256=proton_script_sha256,
|
||||
proton_version_sha256=proton_version_sha256,
|
||||
proton_toolmanifest_sha256=proton_toolmanifest_sha256,
|
||||
proton_wine_sha256=proton_wine_sha256,
|
||||
proton_wineserver_sha256=proton_wineserver_sha256,
|
||||
prefix_dll_sha256=prefix_dll_sha256,
|
||||
registry_overrides=_runtime_registry_override_flags(prefix),
|
||||
runtime_marker_matches_selection=marker_matches,
|
||||
)
|
||||
|
||||
|
||||
def _runtime_registry_override_flags(prefix: Path) -> dict[str, bool]:
|
||||
try:
|
||||
registry = (prefix / "user.reg").read_text(
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
).casefold()
|
||||
except OSError:
|
||||
registry = ""
|
||||
return {
|
||||
"d3d9_native_builtin": '"d3d9"="native,builtin"' in registry,
|
||||
"d3dx9_43_native": _native_override_present(registry, "d3dx9_43"),
|
||||
"d3dcompiler_43_native": _native_override_present(
|
||||
registry,
|
||||
"d3dcompiler_43",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _native_override_present(registry: str, name: str) -> bool:
|
||||
return any(
|
||||
entry in registry
|
||||
for entry in (
|
||||
f'"{name}"="native,builtin"',
|
||||
f'"{name}"="native"',
|
||||
f'"*{name}"="native"',
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def classify_game_profile(
|
||||
game_exe_sha256: str | None,
|
||||
game_dll_sha256: str | None,
|
||||
) -> str:
|
||||
if game_exe_sha256 is None or game_dll_sha256 is None:
|
||||
return "missing"
|
||||
pair = {"Game.exe": game_exe_sha256, "game.dll": game_dll_sha256}
|
||||
if pair == CURRENT_V21_BINARY_HASHES:
|
||||
return "v21.75.83"
|
||||
if (
|
||||
game_exe_sha256 == CURRENT_V21_BINARY_HASHES["Game.exe"]
|
||||
and game_dll_sha256 == RETIRED_POST_AUTH_GAME_DLL_SHA256
|
||||
):
|
||||
return "retired-post-auth-disk-patch"
|
||||
if (
|
||||
game_exe_sha256 == CURRENT_V21_BINARY_HASHES["Game.exe"]
|
||||
and game_dll_sha256 == PATCHED_V21_GAME_DLL_SHA256
|
||||
):
|
||||
return "retired-texture-reclaim"
|
||||
if pair == COMPATIBILITY_BINARY_HASHES:
|
||||
return "verified-compatibility"
|
||||
known_game_exe_hashes = {
|
||||
CURRENT_V21_BINARY_HASHES["Game.exe"],
|
||||
COMPATIBILITY_BINARY_HASHES["Game.exe"],
|
||||
}
|
||||
known_game_dll_hashes = {
|
||||
CURRENT_V21_BINARY_HASHES["game.dll"],
|
||||
RETIRED_POST_AUTH_GAME_DLL_SHA256,
|
||||
PATCHED_V21_GAME_DLL_SHA256,
|
||||
COMPATIBILITY_BINARY_HASHES["game.dll"],
|
||||
}
|
||||
if game_exe_sha256 in known_game_exe_hashes or game_dll_sha256 in known_game_dll_hashes:
|
||||
return "mismatched"
|
||||
return "unknown"
|
||||
|
||||
|
||||
def latest_native_game_log(paths: AppPaths) -> Path | None:
|
||||
"""Return the newest native log from the active managed source or runtime view."""
|
||||
|
||||
directories = (paths.runtime_view / "save/logs", paths.source_game / "save/logs")
|
||||
logs: dict[Path, Path] = {}
|
||||
for directory in directories:
|
||||
try:
|
||||
candidates = directory.glob("log_*.txt")
|
||||
for candidate in candidates:
|
||||
if candidate.is_file():
|
||||
logs[candidate.resolve()] = candidate
|
||||
except OSError:
|
||||
continue
|
||||
if not logs:
|
||||
return None
|
||||
try:
|
||||
return max(logs.values(), key=lambda item: item.stat().st_mtime_ns)
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def _active_game_root(paths: AppPaths) -> Path:
|
||||
if paths.runtime_view.is_dir():
|
||||
return paths.runtime_view
|
||||
return paths.source_game
|
||||
|
||||
|
||||
def _runtime_selection(settings: Settings) -> tuple[RuntimeSelection | None, str | None]:
|
||||
try:
|
||||
return select_runtime(settings), None
|
||||
except RuntimeError as exc:
|
||||
return None, str(exc)
|
||||
|
||||
|
||||
def _runtime_path(selection: RuntimeSelection | None) -> str | None:
|
||||
if selection is None:
|
||||
return None
|
||||
return selection.proton_path or selection.executable
|
||||
|
||||
|
||||
def _runtime_prefix(paths: AppPaths, selection: RuntimeSelection | None) -> Path | None:
|
||||
if selection is None:
|
||||
return None
|
||||
return (
|
||||
paths.proton_prefix
|
||||
if selection.kind in {"proton7", "proton7-container"}
|
||||
else paths.umu_prefix
|
||||
)
|
||||
|
||||
|
||||
def _prefix_ready(prefix: Path | None) -> bool:
|
||||
if prefix is None:
|
||||
return False
|
||||
return (prefix / "user.reg").is_file() and (
|
||||
prefix / "drive_c/windows/system32/cmd.exe"
|
||||
).is_file()
|
||||
|
||||
|
||||
def _diagnostic_warnings(
|
||||
probe: LoginServerProbe,
|
||||
game_profile: str,
|
||||
runtime_error: str | None,
|
||||
prefix_ready: bool,
|
||||
directx_ready: bool,
|
||||
latest_log: Path | None,
|
||||
runtime_matches_managed_source: bool,
|
||||
identity_probe: IdentityProbeResult | None,
|
||||
) -> tuple[str, ...]:
|
||||
warnings: list[str] = []
|
||||
if not probe.reachable:
|
||||
warnings.append(
|
||||
"The publisher login port is unreachable from this Linux host; check the "
|
||||
"network, firewall, VPN, or server status."
|
||||
)
|
||||
if game_profile == "missing":
|
||||
warnings.append("Game.exe or game.dll is missing; install or update the game first.")
|
||||
elif game_profile == "mismatched":
|
||||
warnings.append(
|
||||
"Game.exe and game.dll are from different verified releases; run update --repair."
|
||||
)
|
||||
elif game_profile == "unknown":
|
||||
warnings.append("Game.exe/game.dll do not match a launcher-verified client profile.")
|
||||
elif game_profile == "retired-texture-reclaim":
|
||||
warnings.append(
|
||||
"A retired experimental game.dll patch is active and can stall login; "
|
||||
"run prepare or launch the game once to restore the publisher DLL."
|
||||
)
|
||||
elif game_profile == "retired-post-auth-disk-patch":
|
||||
warnings.append(
|
||||
"The retired 0.5.8 disk-patched game.dll is active and can stall login; "
|
||||
"run prepare or launch with the current launcher to restore the publisher DLL."
|
||||
)
|
||||
if not runtime_matches_managed_source:
|
||||
warnings.append(
|
||||
"The runtime Game.exe/game.dll pair does not match the managed updater "
|
||||
"source; run prepare after update --repair before testing login again."
|
||||
)
|
||||
if runtime_error is not None:
|
||||
warnings.append(runtime_error)
|
||||
elif not prefix_ready:
|
||||
warnings.append("The selected game prefix is not initialized; run play once to prepare it.")
|
||||
elif not directx_ready:
|
||||
warnings.append("The native DirectX helper DLLs are not ready; run play to repair them.")
|
||||
if latest_log is None:
|
||||
warnings.append("No native game log was found yet.")
|
||||
if identity_probe is not None:
|
||||
warnings.extend(identity_probe.warnings)
|
||||
return tuple(warnings)
|
||||
|
||||
|
||||
def _read_json_object(path: Path) -> dict[str, object]:
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeError, json.JSONDecodeError):
|
||||
return {}
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
def _integer_or_none(value: object) -> int | None:
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
def _string_or_none(value: object) -> str | None:
|
||||
return value if isinstance(value, str) else None
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _sha256_if_file(path: Path) -> str | None:
|
||||
try:
|
||||
return _sha256(path) if path.is_file() else None
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def _modified_at(path: Path | None) -> str | None:
|
||||
if path is None:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromtimestamp(path.stat().st_mtime).astimezone().isoformat()
|
||||
except OSError:
|
||||
return None
|
||||
20
ptlinux/login_trace.py
Normal file
20
ptlinux/login_trace.py
Normal file
@@ -0,0 +1,20 @@
|
||||
"""Retired login instrumentation retained only for command compatibility."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
DEFAULT_TRACE_TIMEOUT_SECONDS = 10 * 60.0
|
||||
LOGIN_TRACE_ENABLED = False
|
||||
LOGIN_TRACE_DISABLED_REASON = (
|
||||
"login-trace is retired and will not start the game: it enabled Wine relay "
|
||||
"instrumentation during authentication, while the current client performs "
|
||||
"anti-debug and module-integrity checks. Use login-diagnostics for a "
|
||||
"credential-free check, then use wine-update for one controlled comparison "
|
||||
"through the unmodified publisher launcher."
|
||||
)
|
||||
|
||||
|
||||
def run_login_trace(*_args: object, **_kwargs: object) -> None:
|
||||
"""Refuse to reactivate the retired relay-based authentication trace."""
|
||||
|
||||
raise RuntimeError(LOGIN_TRACE_DISABLED_REASON)
|
||||
BIN
ptlinux/native/pt-identity-probe.exe
Normal file
BIN
ptlinux/native/pt-identity-probe.exe
Normal file
Binary file not shown.
@@ -20,6 +20,8 @@ OFFICIAL_LOGO_URL = "https://pristontale.eu/public/img/pristontale/logo/logo_sma
|
||||
BROADCAST_ENDPOINT = "http://5.135.35.188:90/broadcasts"
|
||||
MAX_NEWS_BYTES = 2 * 1024 * 1024
|
||||
MAX_NEWS_IMAGE_BYTES = 12 * 1024 * 1024
|
||||
MAX_BROADCAST_AUTHOR_CHARACTERS = 100
|
||||
MAX_BROADCAST_CONTENT_CHARACTERS = 2000
|
||||
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
|
||||
|
||||
|
||||
@@ -153,7 +155,13 @@ def fetch_news(
|
||||
_write_cache(cache_path, articles)
|
||||
return articles
|
||||
except (OSError, NewsError, UnicodeDecodeError, json.JSONDecodeError):
|
||||
return _read_cache(cache_path)
|
||||
return read_cached_news(cache_path)
|
||||
|
||||
|
||||
def read_cached_news(cache_path: Path) -> tuple[NewsArticle, ...]:
|
||||
"""Read the last valid news feed without performing network I/O."""
|
||||
|
||||
return _read_cache(cache_path)
|
||||
|
||||
|
||||
def fetch_latest_broadcast(
|
||||
@@ -179,7 +187,13 @@ def fetch_latest_broadcast(
|
||||
_write_broadcast_cache(cache_path, broadcast)
|
||||
return broadcast
|
||||
except (OSError, NewsError, UnicodeDecodeError, json.JSONDecodeError):
|
||||
return _read_broadcast_cache(cache_path)
|
||||
return read_cached_broadcast(cache_path)
|
||||
|
||||
|
||||
def read_cached_broadcast(cache_path: Path) -> Broadcast | None:
|
||||
"""Read the last valid broadcast without performing network I/O."""
|
||||
|
||||
return _read_broadcast_cache(cache_path)
|
||||
|
||||
|
||||
def _parse_news(payload: bytes) -> tuple[NewsArticle, ...]:
|
||||
@@ -229,26 +243,41 @@ 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")
|
||||
return _broadcast_from_mapping(value[0])
|
||||
|
||||
|
||||
def _broadcast_from_mapping(latest: dict[object, object]) -> Broadcast:
|
||||
author = _bounded_text(latest.get("author"), MAX_BROADCAST_AUTHOR_CHARACTERS)
|
||||
content = _bounded_text(latest.get("content"), MAX_BROADCAST_CONTENT_CHARACTERS)
|
||||
url = _broadcast_url(latest.get("url"))
|
||||
if (
|
||||
not isinstance(author, str)
|
||||
or not author.strip()
|
||||
or not isinstance(content, str)
|
||||
or not content.strip()
|
||||
author is None
|
||||
or content is None
|
||||
or url is None
|
||||
):
|
||||
raise NewsError("latest broadcast is incomplete")
|
||||
published_at = (
|
||||
_format_timestamp(latest.get("timestamp"))
|
||||
if "timestamp" in latest
|
||||
else _bounded_text(latest.get("published_at"), 64)
|
||||
)
|
||||
return Broadcast(
|
||||
author=author.strip(),
|
||||
content=content.strip(),
|
||||
published_at=_format_timestamp(latest.get("timestamp")),
|
||||
author=author,
|
||||
content=content,
|
||||
published_at=published_at,
|
||||
url=url,
|
||||
)
|
||||
|
||||
|
||||
def _bounded_text(value: object, maximum_characters: int) -> str | None:
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
text = value.strip()
|
||||
if not text or len(text) > maximum_characters:
|
||||
return None
|
||||
return text
|
||||
|
||||
|
||||
def _format_timestamp(value: object) -> str | None:
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
return None
|
||||
@@ -276,10 +305,16 @@ def _image_url(value: object) -> str | None:
|
||||
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:
|
||||
url = value.strip()
|
||||
parsed = urlparse(url)
|
||||
if (
|
||||
parsed.scheme.casefold() != "https"
|
||||
or not parsed.netloc
|
||||
or parsed.username is not None
|
||||
or parsed.password is not None
|
||||
):
|
||||
return None
|
||||
return value.strip()
|
||||
return url
|
||||
|
||||
|
||||
def _read_cache(path: Path) -> tuple[NewsArticle, ...]:
|
||||
@@ -288,7 +323,7 @@ def _read_cache(path: Path) -> tuple[NewsArticle, ...]:
|
||||
if not isinstance(value, list):
|
||||
return ()
|
||||
return tuple(NewsArticle(**item) for item in value if isinstance(item, dict))
|
||||
except (OSError, TypeError, json.JSONDecodeError):
|
||||
except (OSError, UnicodeError, TypeError, json.JSONDecodeError):
|
||||
return ()
|
||||
|
||||
|
||||
@@ -308,8 +343,8 @@ def _write_cache(path: Path, articles: tuple[NewsArticle, ...]) -> None:
|
||||
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 _broadcast_from_mapping(value) if isinstance(value, dict) else None
|
||||
except (OSError, UnicodeError, NewsError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ from ptdiag.assbin import (
|
||||
)
|
||||
|
||||
from .paths import AppPaths
|
||||
from .v21_texture_reclaim import TextureReclaimPatchError, build_texture_reclaim_patch
|
||||
|
||||
|
||||
WHISPERING_VALE_RELATIVE = Path("game/maps/WhisperingVale.rovug")
|
||||
@@ -42,6 +41,10 @@ CURRENT_V21_BINARY_HASHES = {
|
||||
PATCHED_V21_GAME_DLL_SHA256 = (
|
||||
"0be11b06ccf9be747e32b195c8d5980062f446d7d3a0a662ebfe0e602a01c538"
|
||||
)
|
||||
RETIRED_POST_AUTH_GAME_DLL_SHA256 = (
|
||||
"93cd27284eb294753da1111c99bfc9689f82b6d4a59302aee9fdbd8cb52452dd"
|
||||
)
|
||||
RUNTIME_VIEW_CONFIGURATION_VERSION = 1
|
||||
|
||||
|
||||
class OverlayError(RuntimeError):
|
||||
@@ -72,11 +75,59 @@ class CompatibilityCacheResult:
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RuntimePatchCacheResult:
|
||||
source: str
|
||||
destination: str
|
||||
game_dll_sha256: str
|
||||
created_at: str
|
||||
class RuntimeCompatibility:
|
||||
"""Read-only compatibility result for the managed game currently on disk."""
|
||||
|
||||
compatible: bool
|
||||
source_game: str
|
||||
source_scene_sha256: str | None
|
||||
scene_action: str | None
|
||||
binary_action: str | None
|
||||
reason: str | None
|
||||
|
||||
|
||||
def inspect_runtime_compatibility(
|
||||
paths: AppPaths,
|
||||
source_game: Path,
|
||||
) -> RuntimeCompatibility:
|
||||
"""Check whether ``prepare_runtime_view`` can recognize the installed release.
|
||||
|
||||
This preflight is intentionally read-only: it never normalizes a scene,
|
||||
builds the verified runtime DLL, prepares save data, or replaces the runtime
|
||||
view. The normal preparation path still performs every authoritative check
|
||||
immediately before launch.
|
||||
"""
|
||||
|
||||
requested_source = source_game.expanduser()
|
||||
source_scene_sha256 = None
|
||||
scene_action = None
|
||||
binary_action = None
|
||||
try:
|
||||
source = requested_source.resolve(strict=True)
|
||||
_validate_game_tree(source)
|
||||
source_scene = source / WHISPERING_VALE_RELATIVE
|
||||
inspection = inspect_wrapped_assbin(source_scene)
|
||||
source_scene_sha256 = inspection.file_sha256
|
||||
scene_action = _scene_action(inspection)
|
||||
binary_action = _inspect_runtime_binary_action(paths, source)
|
||||
except (OSError, ValueError, OverlayError) as exc:
|
||||
reason = str(exc).strip() or type(exc).__name__
|
||||
return RuntimeCompatibility(
|
||||
compatible=False,
|
||||
source_game=str(requested_source.resolve(strict=False)),
|
||||
source_scene_sha256=source_scene_sha256,
|
||||
scene_action=scene_action,
|
||||
binary_action=binary_action,
|
||||
reason=reason,
|
||||
)
|
||||
return RuntimeCompatibility(
|
||||
compatible=True,
|
||||
source_game=str(source),
|
||||
source_scene_sha256=source_scene_sha256,
|
||||
scene_action=scene_action,
|
||||
binary_action=binary_action,
|
||||
reason=None,
|
||||
)
|
||||
|
||||
|
||||
def prepare_runtime_view(
|
||||
@@ -91,17 +142,36 @@ def prepare_runtime_view(
|
||||
source_scene = source / WHISPERING_VALE_RELATIVE
|
||||
inspection = inspect_wrapped_assbin(source_scene)
|
||||
action = _scene_action(inspection)
|
||||
binary_action, game_exe_source, game_dll_source = _runtime_game_binary_sources(
|
||||
game_save = _prepare_persistent_save(paths, source)
|
||||
(
|
||||
binary_action,
|
||||
game_exe_source,
|
||||
game_dll_source,
|
||||
source_binary_hashes,
|
||||
) = _runtime_game_binary_profile(
|
||||
paths,
|
||||
source,
|
||||
)
|
||||
reused = _reuse_runtime_view(
|
||||
paths,
|
||||
source,
|
||||
inspection,
|
||||
action,
|
||||
game_save,
|
||||
binary_action,
|
||||
game_exe_source,
|
||||
game_dll_source,
|
||||
source_binary_hashes,
|
||||
)
|
||||
if reused is not None:
|
||||
return reused
|
||||
|
||||
build = paths.data / f".{paths.runtime_view.name}.build-{uuid.uuid4().hex}"
|
||||
backup = paths.data / f".{paths.runtime_view.name}.old-{uuid.uuid4().hex}"
|
||||
links_created = 0
|
||||
try:
|
||||
build.mkdir(parents=True, exist_ok=False)
|
||||
top_level_exceptions = {"game"}
|
||||
top_level_exceptions = {"game", "save"}
|
||||
for name, binary_source in (
|
||||
("Game.exe", game_exe_source),
|
||||
("game.dll", game_dll_source),
|
||||
@@ -113,6 +183,8 @@ def prepare_runtime_view(
|
||||
build,
|
||||
except_names=top_level_exceptions,
|
||||
)
|
||||
(build / "save").symlink_to(game_save, target_is_directory=True)
|
||||
links_created += 1
|
||||
if game_exe_source != source / "Game.exe":
|
||||
(build / "Game.exe").symlink_to(game_exe_source)
|
||||
links_created += 1
|
||||
@@ -165,7 +237,13 @@ def prepare_runtime_view(
|
||||
created_at=datetime.now().astimezone().isoformat(),
|
||||
links_created=links_created,
|
||||
)
|
||||
_write_json(paths.runtime_manifest, asdict(result))
|
||||
_write_json(
|
||||
paths.runtime_manifest,
|
||||
{
|
||||
"configuration_version": RUNTIME_VIEW_CONFIGURATION_VERSION,
|
||||
**asdict(result),
|
||||
},
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@@ -202,50 +280,43 @@ def cache_compatibility_binaries(
|
||||
return result
|
||||
|
||||
|
||||
def cache_runtime_patch(
|
||||
paths: AppPaths,
|
||||
source_dll: Path,
|
||||
) -> RuntimePatchCacheResult:
|
||||
source = source_dll.expanduser().resolve(strict=True)
|
||||
if _sha256(source) != PATCHED_V21_GAME_DLL_SHA256:
|
||||
raise OverlayError("runtime patch does not match the verified V21 profile")
|
||||
target = paths.runtime_patch_dll
|
||||
if target.exists() or target.is_symlink():
|
||||
if target.is_file() and _sha256(target) == PATCHED_V21_GAME_DLL_SHA256:
|
||||
return _runtime_patch_result(paths, source)
|
||||
raise OverlayError(f"runtime patch cache has an unknown profile: {target}")
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = target.with_name(f".{target.name}.{uuid.uuid4().hex}.tmp")
|
||||
try:
|
||||
shutil.copy2(source, temporary)
|
||||
if _sha256(temporary) != PATCHED_V21_GAME_DLL_SHA256:
|
||||
raise OverlayError("copied runtime patch failed hash verification")
|
||||
temporary.replace(target)
|
||||
except BaseException:
|
||||
temporary.unlink(missing_ok=True)
|
||||
raise
|
||||
result = _runtime_patch_result(paths, source)
|
||||
_write_json(paths.runtime_patch_manifest, asdict(result))
|
||||
return result
|
||||
|
||||
|
||||
def select_binary_action(
|
||||
cache_hashes: dict[str, str] | None,
|
||||
source_hashes: dict[str, str],
|
||||
runtime_patch_hash: str | None = None,
|
||||
) -> str:
|
||||
if runtime_patch_hash is not None:
|
||||
if runtime_patch_hash != PATCHED_V21_GAME_DLL_SHA256:
|
||||
raise OverlayError("runtime patch cache has an unknown binary profile")
|
||||
if cache_hashes is not None:
|
||||
if cache_hashes != COMPATIBILITY_BINARY_HASHES:
|
||||
raise OverlayError("compatibility cache has an unknown binary profile")
|
||||
if source_hashes == COMPATIBILITY_BINARY_HASHES:
|
||||
return "verified-source-pair"
|
||||
if runtime_patch_hash is not None:
|
||||
if source_hashes != CURRENT_V21_BINARY_HASHES:
|
||||
raise OverlayError("V21 runtime patch does not match the installed client profile")
|
||||
return "verified-v21-texture-reclaim"
|
||||
if source_hashes == CURRENT_V21_BINARY_HASHES:
|
||||
return "verified-v21-publisher-pair"
|
||||
game_exe_sha256 = source_hashes.get("Game.exe")
|
||||
game_dll_sha256 = source_hashes.get("game.dll")
|
||||
if game_dll_sha256 in {
|
||||
PATCHED_V21_GAME_DLL_SHA256,
|
||||
RETIRED_POST_AUTH_GAME_DLL_SHA256,
|
||||
}:
|
||||
raise OverlayError(
|
||||
"a retired experimental game.dll is installed; run update --repair "
|
||||
"before launching"
|
||||
)
|
||||
known_game_exe_hashes = {
|
||||
CURRENT_V21_BINARY_HASHES["Game.exe"],
|
||||
COMPATIBILITY_BINARY_HASHES["Game.exe"],
|
||||
}
|
||||
known_game_dll_hashes = {
|
||||
CURRENT_V21_BINARY_HASHES["game.dll"],
|
||||
COMPATIBILITY_BINARY_HASHES["game.dll"],
|
||||
}
|
||||
if (
|
||||
game_exe_sha256 in known_game_exe_hashes
|
||||
or game_dll_sha256 in known_game_dll_hashes
|
||||
):
|
||||
raise OverlayError(
|
||||
"Game.exe and game.dll are from different verified releases; run "
|
||||
"update --repair before launching"
|
||||
)
|
||||
return "upstream-pair"
|
||||
|
||||
|
||||
@@ -349,26 +420,287 @@ def _link_level(source: Path, target: Path, *, except_names: set[str]) -> int:
|
||||
return count
|
||||
|
||||
|
||||
def _runtime_game_binary_sources(paths: AppPaths, source: Path) -> tuple[str, Path, Path]:
|
||||
def _prepare_persistent_save(paths: AppPaths, source: Path) -> Path:
|
||||
"""Migrate game-created settings and logs before replacing the runtime view."""
|
||||
|
||||
target = paths.game_save
|
||||
if target.is_symlink():
|
||||
raise OverlayError(f"persistent game save must not be a symbolic link: {target}")
|
||||
if target.exists():
|
||||
if not target.is_dir():
|
||||
raise OverlayError(f"persistent game save is not a directory: {target}")
|
||||
_reject_save_symlinks(target)
|
||||
return target
|
||||
|
||||
candidate = None
|
||||
for path in (paths.runtime_view / "save", source / "save"):
|
||||
if path.is_dir():
|
||||
candidate = path.resolve(strict=True)
|
||||
break
|
||||
if candidate is not None:
|
||||
selected = candidate
|
||||
allowed_roots = (paths.data.resolve(strict=False), source.resolve(strict=True))
|
||||
if not any(
|
||||
selected == root or selected.is_relative_to(root)
|
||||
for root in allowed_roots
|
||||
):
|
||||
raise OverlayError(f"refusing to migrate game save outside managed roots: {selected}")
|
||||
_reject_save_symlinks(selected)
|
||||
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
staging = target.parent / f".{target.name}.build-{uuid.uuid4().hex}"
|
||||
try:
|
||||
if candidate is None:
|
||||
staging.mkdir(parents=False, exist_ok=False)
|
||||
else:
|
||||
shutil.copytree(candidate, staging, symlinks=True)
|
||||
staging.replace(target)
|
||||
except BaseException:
|
||||
_remove_runtime_tree(staging)
|
||||
raise
|
||||
return target
|
||||
|
||||
|
||||
def _reject_save_symlinks(root: Path) -> None:
|
||||
for directory, directory_names, file_names in os.walk(root, followlinks=False):
|
||||
parent = Path(directory)
|
||||
for name in (*directory_names, *file_names):
|
||||
path = parent / name
|
||||
if path.is_symlink():
|
||||
raise OverlayError(f"persistent game save contains a symbolic link: {path}")
|
||||
|
||||
|
||||
def _reuse_runtime_view(
|
||||
paths: AppPaths,
|
||||
source: Path,
|
||||
inspection: SceneInspection,
|
||||
scene_action: str,
|
||||
game_save: Path,
|
||||
binary_action: str,
|
||||
game_exe_source: Path,
|
||||
game_dll_source: Path,
|
||||
source_binary_hashes: Mapping[str, str],
|
||||
) -> OverlayResult | None:
|
||||
result = _read_overlay_result(paths.runtime_manifest)
|
||||
if result is None:
|
||||
return None
|
||||
if result.source_game != str(source):
|
||||
return None
|
||||
if result.runtime_view != str(paths.runtime_view):
|
||||
return None
|
||||
if result.source_scene_sha256 != inspection.file_sha256:
|
||||
return None
|
||||
if result.scene_action != scene_action or result.binary_action != binary_action:
|
||||
return None
|
||||
if result.game_exe_sha256 != source_binary_hashes.get("Game.exe"):
|
||||
return None
|
||||
if result.game_dll_sha256 != source_binary_hashes.get("game.dll"):
|
||||
return None
|
||||
if not _runtime_layout_ready(
|
||||
paths,
|
||||
source,
|
||||
game_save,
|
||||
game_exe_source,
|
||||
game_dll_source,
|
||||
result.links_created,
|
||||
):
|
||||
return None
|
||||
runtime_scene = paths.runtime_view / WHISPERING_VALE_RELATIVE
|
||||
try:
|
||||
if _sha256(runtime_scene) != result.runtime_scene_sha256:
|
||||
return None
|
||||
except OSError:
|
||||
return None
|
||||
return result
|
||||
|
||||
|
||||
def _runtime_layout_ready(
|
||||
paths: AppPaths,
|
||||
source: Path,
|
||||
game_save: Path,
|
||||
game_exe_source: Path,
|
||||
game_dll_source: Path,
|
||||
expected_links: int,
|
||||
) -> bool:
|
||||
runtime = paths.runtime_view
|
||||
if runtime.is_symlink() or not runtime.is_dir():
|
||||
return False
|
||||
top_level_exceptions = {"game", "save"}
|
||||
top_level_special = {"game", "save"}
|
||||
for name, binary_source in (
|
||||
("Game.exe", game_exe_source),
|
||||
("game.dll", game_dll_source),
|
||||
):
|
||||
if binary_source != source / name:
|
||||
top_level_exceptions.add(name)
|
||||
top_level_special.add(name)
|
||||
top_level_ready, top_level_links = _linked_level_ready(
|
||||
source,
|
||||
runtime,
|
||||
except_names=top_level_exceptions,
|
||||
special_names=top_level_special,
|
||||
)
|
||||
if not top_level_ready:
|
||||
return False
|
||||
if not _symlink_points_to(runtime / "save", game_save):
|
||||
return False
|
||||
if game_exe_source != source / "Game.exe" and not _symlink_points_to(
|
||||
runtime / "Game.exe",
|
||||
game_exe_source,
|
||||
):
|
||||
return False
|
||||
if game_dll_source != source / "game.dll" and not _symlink_points_to(
|
||||
runtime / "game.dll",
|
||||
game_dll_source,
|
||||
):
|
||||
return False
|
||||
|
||||
game_source = source / "game"
|
||||
game_target = runtime / "game"
|
||||
if game_target.is_symlink() or not game_target.is_dir():
|
||||
return False
|
||||
game_ready, game_links = _linked_level_ready(
|
||||
game_source,
|
||||
game_target,
|
||||
except_names={"maps"},
|
||||
special_names={"maps"},
|
||||
)
|
||||
if not game_ready:
|
||||
return False
|
||||
|
||||
maps_source = game_source / "maps"
|
||||
maps_target = game_target / "maps"
|
||||
if maps_target.is_symlink() or not maps_target.is_dir():
|
||||
return False
|
||||
maps_ready, maps_links = _linked_level_ready(
|
||||
maps_source,
|
||||
maps_target,
|
||||
except_names={"WhisperingVale.rovug"},
|
||||
special_names={"WhisperingVale.rovug"},
|
||||
)
|
||||
if not maps_ready:
|
||||
return False
|
||||
runtime_scene = maps_target / "WhisperingVale.rovug"
|
||||
if runtime_scene.is_symlink() or not runtime_scene.is_file():
|
||||
return False
|
||||
actual_links = top_level_links + game_links + maps_links + 1
|
||||
if game_exe_source != source / "Game.exe":
|
||||
actual_links += 1
|
||||
if game_dll_source != source / "game.dll":
|
||||
actual_links += 1
|
||||
return actual_links == expected_links
|
||||
|
||||
|
||||
def _linked_level_ready(
|
||||
source: Path,
|
||||
target: Path,
|
||||
*,
|
||||
except_names: set[str],
|
||||
special_names: set[str],
|
||||
) -> tuple[bool, int]:
|
||||
try:
|
||||
source_children = {
|
||||
child.name: child
|
||||
for child in source.iterdir()
|
||||
if child.name not in except_names
|
||||
}
|
||||
target_names = {child.name for child in target.iterdir()}
|
||||
except OSError:
|
||||
return False, 0
|
||||
if target_names != set(source_children) | special_names:
|
||||
return False, 0
|
||||
for name, child in source_children.items():
|
||||
if not _symlink_points_to(target / name, child):
|
||||
return False, 0
|
||||
return True, len(source_children)
|
||||
|
||||
|
||||
def _symlink_points_to(link: Path, expected_target: Path) -> bool:
|
||||
if not link.is_symlink():
|
||||
return False
|
||||
try:
|
||||
return link.resolve(strict=True) == expected_target.resolve(strict=True)
|
||||
except (OSError, RuntimeError):
|
||||
return False
|
||||
|
||||
|
||||
def _read_overlay_result(path: Path) -> OverlayResult | None:
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeError, json.JSONDecodeError):
|
||||
return None
|
||||
if not isinstance(value, dict):
|
||||
return None
|
||||
if value.get("configuration_version") != RUNTIME_VIEW_CONFIGURATION_VERSION:
|
||||
return None
|
||||
string_fields = (
|
||||
"source_game",
|
||||
"runtime_view",
|
||||
"source_scene_sha256",
|
||||
"runtime_scene_sha256",
|
||||
"scene_action",
|
||||
"binary_action",
|
||||
"game_exe_sha256",
|
||||
"game_dll_sha256",
|
||||
"created_at",
|
||||
)
|
||||
if not all(isinstance(value.get(name), str) for name in string_fields):
|
||||
return None
|
||||
links_created = value.get("links_created")
|
||||
if isinstance(links_created, bool) or not isinstance(links_created, int):
|
||||
return None
|
||||
if links_created < 0:
|
||||
return None
|
||||
return OverlayResult(
|
||||
source_game=value["source_game"],
|
||||
runtime_view=value["runtime_view"],
|
||||
source_scene_sha256=value["source_scene_sha256"],
|
||||
runtime_scene_sha256=value["runtime_scene_sha256"],
|
||||
scene_action=value["scene_action"],
|
||||
binary_action=value["binary_action"],
|
||||
game_exe_sha256=value["game_exe_sha256"],
|
||||
game_dll_sha256=value["game_dll_sha256"],
|
||||
created_at=value["created_at"],
|
||||
links_created=links_created,
|
||||
)
|
||||
|
||||
|
||||
def _runtime_game_binary_sources(
|
||||
paths: AppPaths,
|
||||
source: Path,
|
||||
) -> tuple[str, Path, Path]:
|
||||
action, game_exe, game_dll, _ = _runtime_game_binary_profile(paths, source)
|
||||
return action, game_exe, game_dll
|
||||
|
||||
|
||||
def _runtime_game_binary_profile(
|
||||
paths: AppPaths,
|
||||
source: Path,
|
||||
) -> tuple[str, Path, Path, dict[str, str]]:
|
||||
cache = paths.compatibility_binaries
|
||||
cache_hashes = _binary_hashes(cache) if cache.is_dir() else None
|
||||
source_hashes = _binary_hashes(source)
|
||||
patch = paths.runtime_patch_dll
|
||||
if source_hashes == CURRENT_V21_BINARY_HASHES and not patch.exists():
|
||||
try:
|
||||
result = build_texture_reclaim_patch(source / "game.dll", patch)
|
||||
except TextureReclaimPatchError as exc:
|
||||
raise OverlayError(f"could not build the verified V21 runtime patch: {exc}") from exc
|
||||
_write_json(patch.parent / "patch.json", {"ok": True, **asdict(result)})
|
||||
patch_hash = _sha256(patch) if patch.is_file() else None
|
||||
action = select_binary_action(cache_hashes, source_hashes, patch_hash)
|
||||
action = select_binary_action(cache_hashes, source_hashes)
|
||||
return (
|
||||
action,
|
||||
source / "Game.exe",
|
||||
patch if action == "verified-v21-texture-reclaim" else source / "game.dll",
|
||||
source / "game.dll",
|
||||
source_hashes,
|
||||
)
|
||||
|
||||
|
||||
def _inspect_runtime_binary_action(
|
||||
paths: AppPaths,
|
||||
source: Path,
|
||||
) -> str:
|
||||
"""Mirror publisher binary selection without writing any runtime cache."""
|
||||
|
||||
cache = paths.compatibility_binaries
|
||||
cache_hashes = _binary_hashes(cache) if cache.is_dir() else None
|
||||
source_hashes = _binary_hashes(source)
|
||||
return select_binary_action(cache_hashes, source_hashes)
|
||||
|
||||
|
||||
def _binary_hashes(directory: Path) -> dict[str, str]:
|
||||
return {
|
||||
name: _sha256(directory / name)
|
||||
@@ -387,15 +719,6 @@ def _compatibility_result(paths: AppPaths, source: Path) -> CompatibilityCacheRe
|
||||
)
|
||||
|
||||
|
||||
def _runtime_patch_result(paths: AppPaths, source: Path) -> RuntimePatchCacheResult:
|
||||
return RuntimePatchCacheResult(
|
||||
source=str(source),
|
||||
destination=str(paths.runtime_patch_dll),
|
||||
game_dll_sha256=PATCHED_V21_GAME_DLL_SHA256,
|
||||
created_at=datetime.now().astimezone().isoformat(),
|
||||
)
|
||||
|
||||
|
||||
def _remove_runtime_tree(path: Path) -> None:
|
||||
if path.is_symlink() or path.is_file():
|
||||
path.unlink(missing_ok=True)
|
||||
|
||||
@@ -20,7 +20,13 @@ def _xdg_path(
|
||||
fallback: Path,
|
||||
) -> Path:
|
||||
value = environment.get(variable)
|
||||
return Path(value).expanduser() if value else fallback
|
||||
if not value:
|
||||
return fallback
|
||||
path = Path(value).expanduser()
|
||||
# The XDG Base Directory specification requires absolute paths. Ignoring an
|
||||
# invalid relative value keeps settings anchored to HOME instead of making
|
||||
# their location depend on the launcher's current working directory.
|
||||
return path if path.is_absolute() else fallback
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -91,6 +97,12 @@ class AppPaths:
|
||||
def launcher_update_lock(self) -> Path:
|
||||
return self.state / "launcher-update.lock"
|
||||
|
||||
@property
|
||||
def launcher_update_check_cache(self) -> Path:
|
||||
"""Display-only cache of the last authenticated update check."""
|
||||
|
||||
return self.cache / "launcher-update-check.json"
|
||||
|
||||
def launcher_update_archive(self, version: str) -> Path:
|
||||
return self.downloads / f"pristontale-eu-linux-update-v{version}.tar.gz"
|
||||
|
||||
@@ -118,6 +130,12 @@ class AppPaths:
|
||||
def launcher_operation_lock(self) -> Path:
|
||||
return self.state / "launcher-operation.lock"
|
||||
|
||||
@property
|
||||
def game_runtime_marker(self) -> Path:
|
||||
"""Verified Proton/D3D preparation state for the game prefix."""
|
||||
|
||||
return self.state / "game-runtime.json"
|
||||
|
||||
@property
|
||||
def source_game(self) -> Path:
|
||||
return self.launcher_prefix / GAME_RELATIVE_PATH
|
||||
@@ -126,16 +144,34 @@ class AppPaths:
|
||||
def game_import_manifest(self) -> Path:
|
||||
return self.state / "game-import.json"
|
||||
|
||||
@property
|
||||
def game_import_quarantine(self) -> Path:
|
||||
"""Recoverable files excluded while authenticating an imported game."""
|
||||
|
||||
return self.data / "import-quarantine"
|
||||
|
||||
@property
|
||||
def native_depot_state(self) -> Path:
|
||||
"""Last completed native depot synchronization."""
|
||||
|
||||
return self.state / "native-depot.json"
|
||||
|
||||
@property
|
||||
def depot_launch_catalog_cache(self) -> Path:
|
||||
"""Last official realm choices used for network-free game launches."""
|
||||
|
||||
return self.cache / "depot-launch-catalog.json"
|
||||
|
||||
@property
|
||||
def news_cache(self) -> Path:
|
||||
return self.cache / "news.json"
|
||||
|
||||
@property
|
||||
def patches_cache(self) -> Path:
|
||||
"""Cached official patch changelog shown beside the news feed."""
|
||||
|
||||
return self.cache / "patches.json"
|
||||
|
||||
@property
|
||||
def broadcast_cache(self) -> Path:
|
||||
"""Cached latest announcement from the broadcast service."""
|
||||
@@ -162,6 +198,16 @@ class AppPaths:
|
||||
def runtime_manifest(self) -> Path:
|
||||
return self.state / "runtime-view.json"
|
||||
|
||||
@property
|
||||
def game_user_data(self) -> Path:
|
||||
"""Writable game-created data kept outside the replaceable runtime view."""
|
||||
|
||||
return self.data / "user-data"
|
||||
|
||||
@property
|
||||
def game_save(self) -> Path:
|
||||
return self.game_user_data / "save"
|
||||
|
||||
@property
|
||||
def compatibility_binaries(self) -> Path:
|
||||
return self.data / "compatibility-binaries"
|
||||
@@ -172,16 +218,26 @@ class AppPaths:
|
||||
|
||||
@property
|
||||
def runtime_patch_directory(self) -> Path:
|
||||
return self.data / "runtime-patches" / "v21-texture-reclaim"
|
||||
"""Retired 0.5.8 disk-patch cache; never selected for play."""
|
||||
|
||||
return self.data / "runtime-patches" / "v21-post-auth-reclaim"
|
||||
|
||||
@property
|
||||
def runtime_patch_dll(self) -> Path:
|
||||
"""Retired 0.5.8 DLL retained only so diagnostics can identify it."""
|
||||
|
||||
return self.runtime_patch_directory / "game.dll"
|
||||
|
||||
@property
|
||||
def runtime_patch_manifest(self) -> Path:
|
||||
return self.state / "runtime-patch.json"
|
||||
|
||||
@property
|
||||
def retired_runtime_patch_dll(self) -> Path:
|
||||
"""The pre-0.5.7 all-session patch, retained only for diagnostics."""
|
||||
|
||||
return self.data / "runtime-patches" / "v21-texture-reclaim" / "game.dll"
|
||||
|
||||
@property
|
||||
def compat_data(self) -> Path:
|
||||
return self.data / "game-compat"
|
||||
@@ -206,6 +262,10 @@ class AppPaths:
|
||||
def settings_file(self) -> Path:
|
||||
return self.config / "settings.json"
|
||||
|
||||
@property
|
||||
def settings_lock(self) -> Path:
|
||||
return self.config / "settings.lock"
|
||||
|
||||
def create_base_directories(self) -> None:
|
||||
for path in (
|
||||
self.data,
|
||||
|
||||
@@ -4,20 +4,26 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from pathlib import Path, PurePosixPath
|
||||
import platform
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import sys
|
||||
from typing import Mapping
|
||||
from typing import Mapping, Sequence
|
||||
|
||||
from .command import CommandOutcome, CommandRunner
|
||||
from .command import CommandOutcome, CommandRunner, OutputLineFilter
|
||||
from .depot import DEFAULT_DEPOT_START_OPTION, DepotStartOption
|
||||
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"
|
||||
WINDOWS_GAME_DIRECTORY = r"C:\Program Files (x86)\Pristontale EU"
|
||||
WINDOWS_GAME_EXE = rf"{WINDOWS_GAME_DIRECTORY}\Game.exe"
|
||||
NATIVE_D3D43_DLLS = ("d3dx9_43", "d3dcompiler_43")
|
||||
DXVK_D3D9_OVERRIDE = "native,builtin"
|
||||
DLL_OVERRIDES_KEY = r"HKCU\Software\Wine\DllOverrides"
|
||||
@@ -25,6 +31,18 @@ WINETRICKS_CODEPAGE_PROBE = (
|
||||
'winetricks_early_wine_arch cmd.exe /c "chcp 65001 > nul & echo %$1%"'
|
||||
)
|
||||
WINETRICKS_PROTON7_PROBE = 'winetricks_early_wine_arch cmd.exe /c "echo %$1%"'
|
||||
RUNTIME_DEFAULT_SYNC = "runtime-default"
|
||||
CACHYOS_MARKER = "cachyos"
|
||||
CURRENT_UMU_DISTRIBUTIONS = frozenset(
|
||||
{"arch", "cachyos", "endeavouros", "garuda", "manjaro"}
|
||||
)
|
||||
GAME_RUNTIME_CONFIGURATION_VERSION = 1
|
||||
PROTON7_RELATIVE_PATH = Path("steamapps/common/Proton 7.0/proton")
|
||||
STEAM_LIBRARY_FOLDERS_RELATIVE_PATH = Path("steamapps/libraryfolders.vdf")
|
||||
STEAM_LIBRARY_FOLDERS_MAX_BYTES = 4 * 1024 * 1024
|
||||
_VDF_QUOTED_PAIR = re.compile(
|
||||
r'"((?:\\.|[^"\\])*)"\s*"((?:\\.|[^"\\])*)"'
|
||||
)
|
||||
|
||||
|
||||
class RuntimeErrorPT(RuntimeError):
|
||||
@@ -42,23 +60,79 @@ class RuntimeSelection:
|
||||
def select_runtime(
|
||||
settings: Settings,
|
||||
environment: Mapping[str, str] | None = None,
|
||||
*,
|
||||
distribution_id: str | None = None,
|
||||
kernel_release: str | None = None,
|
||||
) -> RuntimeSelection:
|
||||
env = os.environ if environment is None else environment
|
||||
if settings.proton_path:
|
||||
umu = shutil.which("umu-run", path=env.get("PATH"))
|
||||
current_umu_host = prefers_current_umu_host(
|
||||
distribution_id=distribution_id,
|
||||
kernel_release=kernel_release,
|
||||
)
|
||||
auto_container = (
|
||||
settings.runtime == "auto"
|
||||
and bool(umu)
|
||||
and not current_umu_host
|
||||
)
|
||||
if settings.runtime == "umu":
|
||||
if umu:
|
||||
return RuntimeSelection("umu", umu, None, None)
|
||||
raise RuntimeErrorPT("UMU was requested but umu-run was not found")
|
||||
if settings.proton_path and settings.runtime in {
|
||||
"auto",
|
||||
"proton7",
|
||||
"proton7-container",
|
||||
}:
|
||||
proton = Path(settings.proton_path).expanduser().resolve()
|
||||
if not proton.is_file():
|
||||
raise RuntimeErrorPT(f"configured Proton script does not exist: {proton}")
|
||||
steam_root = _steam_root_for_proton(proton)
|
||||
steam_root = _steam_root_for_proton(proton, env)
|
||||
if settings.runtime == "proton7-container":
|
||||
if not umu:
|
||||
raise RuntimeErrorPT(
|
||||
"containerized Proton 7 was requested but umu-run was not found"
|
||||
)
|
||||
return RuntimeSelection(
|
||||
"proton7-container",
|
||||
umu,
|
||||
str(proton),
|
||||
str(steam_root),
|
||||
)
|
||||
if auto_container:
|
||||
assert umu is not None
|
||||
return RuntimeSelection(
|
||||
"proton7-container",
|
||||
umu,
|
||||
str(proton),
|
||||
str(steam_root),
|
||||
)
|
||||
return RuntimeSelection("proton7", sys.executable, str(proton), str(steam_root))
|
||||
if settings.runtime in {"auto", "proton7"}:
|
||||
# Current UMU avoids several rolling-distribution container/media failures and
|
||||
# remains the broad Arch-family compatibility default. Direct Proton 7 is also
|
||||
# known to authenticate on CachyOS, so this is a compatibility policy rather
|
||||
# than an assertion that Proton 7 itself causes server-side Code 1/Code 3.
|
||||
if settings.runtime == "auto" and current_umu_host and umu:
|
||||
return RuntimeSelection("umu", umu, None, None)
|
||||
if settings.runtime in {"auto", "proton7", "proton7-container"}:
|
||||
proton_result = find_steam_proton7(env)
|
||||
if proton_result is not None:
|
||||
proton, steam_root = proton_result
|
||||
if settings.runtime == "proton7-container" or auto_container:
|
||||
if not umu:
|
||||
raise RuntimeErrorPT(
|
||||
"containerized Proton 7 was requested but umu-run was not found"
|
||||
)
|
||||
return RuntimeSelection(
|
||||
"proton7-container",
|
||||
umu,
|
||||
str(proton),
|
||||
str(steam_root),
|
||||
)
|
||||
return RuntimeSelection("proton7", sys.executable, str(proton), str(steam_root))
|
||||
if settings.runtime == "proton7":
|
||||
if settings.runtime in {"proton7", "proton7-container"}:
|
||||
raise RuntimeErrorPT("Steam Proton 7.0 was requested but not found")
|
||||
if settings.runtime in {"auto", "umu"}:
|
||||
umu = shutil.which("umu-run")
|
||||
if umu:
|
||||
return RuntimeSelection("umu", umu, None, None)
|
||||
raise RuntimeErrorPT(
|
||||
@@ -70,33 +144,108 @@ def select_runtime(
|
||||
def find_steam_proton7(
|
||||
environment: Mapping[str, str] | None = None,
|
||||
) -> tuple[Path, Path] | None:
|
||||
"""Find Proton 7 in a Steam client root or one of its registered libraries.
|
||||
|
||||
The returned Steam root is always the client installation, even when the
|
||||
Proton tool itself lives in a secondary library. Proton 7 uses that client
|
||||
root for Steam's bundled media compatibility libraries.
|
||||
"""
|
||||
|
||||
env = os.environ if environment is None else environment
|
||||
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")
|
||||
for steam_root in _steam_client_roots(env):
|
||||
for library_root in _steam_library_roots(steam_root):
|
||||
proton = library_root / PROTON7_RELATIVE_PATH
|
||||
if proton.is_file():
|
||||
return proton.resolve(), steam_root
|
||||
return None
|
||||
|
||||
|
||||
def _steam_client_roots(environment: Mapping[str, str]) -> tuple[Path, ...]:
|
||||
candidates: list[Path] = []
|
||||
explicit = environment.get("STEAM_COMPAT_CLIENT_INSTALL_PATH")
|
||||
if explicit:
|
||||
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",
|
||||
candidates.append(Path(explicit).expanduser())
|
||||
home_value = environment.get("HOME")
|
||||
if home_value:
|
||||
home = Path(home_value).expanduser()
|
||||
candidates.extend(
|
||||
(
|
||||
home / ".local/share/Steam",
|
||||
home / ".steam/steam",
|
||||
home / ".steam/root",
|
||||
home / ".steam/debian-installation",
|
||||
home / "snap/steam/common/.local/share/Steam",
|
||||
home / "snap/steam/common/.steam/steam",
|
||||
home / ".var/app/com.valvesoftware.Steam/.local/share/Steam",
|
||||
)
|
||||
)
|
||||
return _unique_resolved_paths(candidates)
|
||||
|
||||
|
||||
def _steam_library_roots(steam_root: Path) -> tuple[Path, ...]:
|
||||
registered = _read_steam_library_folders(
|
||||
steam_root / STEAM_LIBRARY_FOLDERS_RELATIVE_PATH
|
||||
)
|
||||
return _unique_resolved_paths((steam_root, *registered))
|
||||
|
||||
|
||||
def _read_steam_library_folders(path: Path) -> tuple[Path, ...]:
|
||||
"""Read current and legacy Steam libraryfolders.vdf path records."""
|
||||
|
||||
try:
|
||||
with path.open("rb") as handle:
|
||||
content = handle.read(STEAM_LIBRARY_FOLDERS_MAX_BYTES + 1)
|
||||
except OSError:
|
||||
return ()
|
||||
if len(content) > STEAM_LIBRARY_FOLDERS_MAX_BYTES:
|
||||
return ()
|
||||
text = content.decode("utf-8-sig", errors="replace")
|
||||
result: list[Path] = []
|
||||
for raw_key, raw_value in _VDF_QUOTED_PAIR.findall(text):
|
||||
key = _decode_vdf_string(raw_key)
|
||||
value = _decode_vdf_string(raw_value)
|
||||
# Current files use a named "path" property. Older Steam clients used
|
||||
# numeric top-level keys, which are distinguishable from app-size pairs
|
||||
# because a Linux library path must be absolute.
|
||||
if key.casefold() != "path" and not key.isdecimal():
|
||||
continue
|
||||
if not value or "\x00" in value:
|
||||
continue
|
||||
library = Path(value).expanduser()
|
||||
if library.is_absolute():
|
||||
result.append(library)
|
||||
return tuple(result)
|
||||
|
||||
|
||||
def _decode_vdf_string(value: str) -> str:
|
||||
result: list[str] = []
|
||||
index = 0
|
||||
while index < len(value):
|
||||
character = value[index]
|
||||
if character == "\\" and index + 1 < len(value):
|
||||
next_character = value[index + 1]
|
||||
if next_character in {'"', "\\"}:
|
||||
result.append(next_character)
|
||||
index += 2
|
||||
continue
|
||||
result.append(character)
|
||||
index += 1
|
||||
return "".join(result)
|
||||
|
||||
|
||||
def _unique_resolved_paths(paths: Sequence[Path]) -> tuple[Path, ...]:
|
||||
result: list[Path] = []
|
||||
seen: set[Path] = set()
|
||||
for root in roots:
|
||||
resolved = root.resolve()
|
||||
for path in paths:
|
||||
try:
|
||||
resolved = path.resolve()
|
||||
except (OSError, RuntimeError):
|
||||
continue
|
||||
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
|
||||
result.append(resolved)
|
||||
return tuple(result)
|
||||
|
||||
|
||||
def prepare_game_runtime(
|
||||
@@ -117,9 +266,11 @@ def prepare_game_runtime(
|
||||
timeout_seconds=1800.0,
|
||||
)
|
||||
)
|
||||
elif selection.kind == "proton7":
|
||||
elif selection.kind in {"proton7", "proton7-container"}:
|
||||
if not selection.proton_path or not selection.steam_root:
|
||||
raise RuntimeErrorPT("incomplete Proton 7 runtime selection")
|
||||
if not runner.dry_run and _game_runtime_ready(paths, selection):
|
||||
return results
|
||||
if not runner.dry_run:
|
||||
paths.compat_data.mkdir(parents=True, exist_ok=True)
|
||||
proton = Path(selection.proton_path)
|
||||
@@ -198,34 +349,69 @@ def prepare_game_runtime(
|
||||
raise RuntimeErrorPT(f"unsupported runtime kind: {selection.kind}")
|
||||
if not runner.dry_run:
|
||||
_write_json(
|
||||
paths.state / "game-runtime.json",
|
||||
paths.game_runtime_marker,
|
||||
{
|
||||
"configuration_version": GAME_RUNTIME_CONFIGURATION_VERSION,
|
||||
"configured_at": datetime.now().astimezone().isoformat(),
|
||||
"native_d3d43": True,
|
||||
"dxvk_d3d9_override": DXVK_D3D9_OVERRIDE,
|
||||
"proton_identity": _proton_identity(selection),
|
||||
"runtime": asdict(selection),
|
||||
},
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
def game_runtime_ready(paths: AppPaths, selection: RuntimeSelection) -> bool:
|
||||
"""Return whether another client may reuse the prepared runtime read-only."""
|
||||
|
||||
if selection.kind == "umu":
|
||||
return native_d3d_ready(paths.umu_prefix)
|
||||
if selection.kind in {"proton7", "proton7-container"}:
|
||||
return _game_runtime_ready(paths, selection)
|
||||
return False
|
||||
|
||||
|
||||
def run_game(
|
||||
paths: AppPaths,
|
||||
settings: Settings,
|
||||
selection: RuntimeSelection,
|
||||
runner: CommandRunner,
|
||||
*,
|
||||
start_option: DepotStartOption = DEFAULT_DEPOT_START_OPTION,
|
||||
use_gamebox: bool = True,
|
||||
concurrent_prefix_launch: bool = False,
|
||||
timeout_seconds: float = 12 * 60 * 60,
|
||||
check: bool = True,
|
||||
extra_environment: Mapping[str, str] | None = None,
|
||||
output_line_filter: OutputLineFilter | None = None,
|
||||
) -> CommandOutcome:
|
||||
if not paths.runtime_view.is_dir() and not runner.dry_run:
|
||||
raise RuntimeErrorPT("runtime view is missing; prepare the game first")
|
||||
native_executable, windows_executable, game_arguments = _game_start_command(
|
||||
paths,
|
||||
start_option,
|
||||
)
|
||||
if selection.kind == "umu":
|
||||
game_command = [
|
||||
selection.executable,
|
||||
str(paths.runtime_view / "Game.exe"),
|
||||
"PTEU_REBORN",
|
||||
native_executable,
|
||||
*game_arguments,
|
||||
]
|
||||
environment = _umu_environment(paths)
|
||||
elif selection.kind == "proton7-container":
|
||||
if not selection.proton_path:
|
||||
raise RuntimeErrorPT("Proton script path is missing")
|
||||
game_command = [
|
||||
selection.executable,
|
||||
native_executable,
|
||||
*game_arguments,
|
||||
]
|
||||
environment = _umu_proton7_environment(
|
||||
paths,
|
||||
selection,
|
||||
synchronization=resolve_proton_sync_mode(settings.proton_sync),
|
||||
)
|
||||
else:
|
||||
if not selection.proton_path:
|
||||
raise RuntimeErrorPT("Proton script path is missing")
|
||||
@@ -235,12 +421,25 @@ def run_game(
|
||||
"ptlinux.proton_compat",
|
||||
selection.proton_path,
|
||||
"runinprefix",
|
||||
WINDOWS_GAME_EXE,
|
||||
"PTEU_REBORN",
|
||||
windows_executable,
|
||||
*game_arguments,
|
||||
]
|
||||
environment = _proton_environment(paths, selection)
|
||||
environment = _proton_environment(
|
||||
paths,
|
||||
selection,
|
||||
synchronization=resolve_proton_sync_mode(settings.proton_sync),
|
||||
)
|
||||
if selection.kind in {"umu", "proton7-container"}:
|
||||
# Keep every UMU launch identical except for the verb, as required when
|
||||
# several games share one prefix. The first client owns prefix startup;
|
||||
# every later shared-lease client joins the active wineserver directly.
|
||||
environment["PROTON_VERB"] = (
|
||||
"runinprefix" if concurrent_prefix_launch else "waitforexitandrun"
|
||||
)
|
||||
if use_gamebox:
|
||||
game_command = build_gamebox_command(game_command, settings)
|
||||
if extra_environment:
|
||||
environment.update(extra_environment)
|
||||
environment["PYTHONPATH"] = _runtime_python_path(
|
||||
os.environ.get("PYTHONPATH"),
|
||||
)
|
||||
@@ -248,8 +447,134 @@ def run_game(
|
||||
game_command,
|
||||
cwd=paths.runtime_view,
|
||||
env_overrides=environment,
|
||||
timeout_seconds=12 * 60 * 60,
|
||||
timeout_seconds=timeout_seconds,
|
||||
check=check,
|
||||
stream_output=True,
|
||||
output_line_filter=output_line_filter,
|
||||
)
|
||||
|
||||
|
||||
def _game_start_command(
|
||||
paths: AppPaths,
|
||||
option: DepotStartOption,
|
||||
) -> tuple[str, str, tuple[str, ...]]:
|
||||
relative = PurePosixPath(option.path)
|
||||
if (
|
||||
relative.is_absolute()
|
||||
or not relative.parts
|
||||
or any(part in {"", ".", ".."} for part in relative.parts)
|
||||
or relative.suffix.casefold() != ".exe"
|
||||
):
|
||||
raise RuntimeErrorPT(f"invalid realm executable path: {option.path!r}")
|
||||
try:
|
||||
arguments = tuple(shlex.split(option.arguments))
|
||||
except ValueError as exc:
|
||||
raise RuntimeErrorPT(
|
||||
f"invalid launch arguments for realm {option.name}: {exc}"
|
||||
) from exc
|
||||
if not arguments:
|
||||
raise RuntimeErrorPT(f"realm {option.name} has no launch arguments")
|
||||
native = str(_resolve_runtime_path_case(paths.runtime_view, relative))
|
||||
windows_relative = "\\".join(relative.parts)
|
||||
return native, rf"{WINDOWS_GAME_DIRECTORY}\{windows_relative}", arguments
|
||||
|
||||
|
||||
def _resolve_runtime_path_case(root: Path, relative: PurePosixPath) -> Path:
|
||||
"""Match Windows manifest casing against the case-sensitive runtime view."""
|
||||
|
||||
direct = root.joinpath(*relative.parts)
|
||||
if direct.exists():
|
||||
return direct
|
||||
cursor = root
|
||||
for part in relative.parts:
|
||||
if not cursor.is_dir():
|
||||
return direct
|
||||
try:
|
||||
matches = tuple(
|
||||
entry for entry in cursor.iterdir() if entry.name.casefold() == part.casefold()
|
||||
)
|
||||
except OSError:
|
||||
return direct
|
||||
if not matches:
|
||||
return direct
|
||||
if len(matches) > 1:
|
||||
raise RuntimeErrorPT(
|
||||
f"realm executable path has ambiguous casing: {relative.as_posix()}"
|
||||
)
|
||||
cursor = matches[0]
|
||||
return cursor
|
||||
|
||||
|
||||
def run_proton_prefix_command(
|
||||
paths: AppPaths,
|
||||
selection: RuntimeSelection,
|
||||
runner: CommandRunner,
|
||||
arguments: Sequence[str],
|
||||
*,
|
||||
check: bool = True,
|
||||
timeout_seconds: float = 60.0,
|
||||
) -> CommandOutcome:
|
||||
"""Run a maintenance command in the pinned Proton 7 prefix."""
|
||||
|
||||
if selection.kind not in {"proton7", "proton7-container"}:
|
||||
raise RuntimeErrorPT("the login trace requires the pinned Proton 7 runtime")
|
||||
if not selection.proton_path or not selection.steam_root:
|
||||
raise RuntimeErrorPT("incomplete Proton 7 runtime selection")
|
||||
return runner.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"ptlinux.proton_compat",
|
||||
selection.proton_path,
|
||||
"runinprefix",
|
||||
*arguments,
|
||||
],
|
||||
env_overrides=_proton_environment(paths, selection),
|
||||
timeout_seconds=timeout_seconds,
|
||||
check=check,
|
||||
)
|
||||
|
||||
|
||||
def run_runtime_prefix_command(
|
||||
paths: AppPaths,
|
||||
selection: RuntimeSelection,
|
||||
runner: CommandRunner,
|
||||
arguments: Sequence[str],
|
||||
*,
|
||||
check: bool = True,
|
||||
timeout_seconds: float = 60.0,
|
||||
) -> CommandOutcome:
|
||||
"""Run a credential-free helper inside the selected game's Wine prefix."""
|
||||
|
||||
if not arguments:
|
||||
raise ValueError("runtime-prefix command arguments cannot be empty")
|
||||
if selection.kind == "umu":
|
||||
command = [selection.executable, *arguments]
|
||||
environment = _umu_environment(paths)
|
||||
elif selection.kind == "proton7-container":
|
||||
if not selection.proton_path or not selection.steam_root:
|
||||
raise RuntimeErrorPT("incomplete Proton 7 runtime selection")
|
||||
command = [selection.executable, *arguments]
|
||||
environment = _umu_proton7_environment(paths, selection)
|
||||
elif selection.kind == "proton7":
|
||||
if not selection.proton_path or not selection.steam_root:
|
||||
raise RuntimeErrorPT("incomplete Proton 7 runtime selection")
|
||||
command = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"ptlinux.proton_compat",
|
||||
selection.proton_path,
|
||||
"runinprefix",
|
||||
*arguments,
|
||||
]
|
||||
environment = _proton_environment(paths, selection)
|
||||
else:
|
||||
raise RuntimeErrorPT(f"unsupported runtime kind: {selection.kind}")
|
||||
return runner.run(
|
||||
command,
|
||||
env_overrides=environment,
|
||||
timeout_seconds=timeout_seconds,
|
||||
check=check,
|
||||
)
|
||||
|
||||
|
||||
@@ -271,13 +596,42 @@ def _umu_environment(paths: AppPaths) -> dict[str, str]:
|
||||
}
|
||||
|
||||
|
||||
def _umu_proton7_environment(
|
||||
paths: AppPaths,
|
||||
selection: RuntimeSelection,
|
||||
*,
|
||||
synchronization: str = RUNTIME_DEFAULT_SYNC,
|
||||
) -> dict[str, str]:
|
||||
"""Run the retained Proton 7 build inside its Steam Runtime container."""
|
||||
|
||||
if not selection.proton_path:
|
||||
raise RuntimeErrorPT("Proton script path is missing")
|
||||
environment = {
|
||||
# UMU expects the Proton compatibility-data root and manages its pfx child.
|
||||
# Pointing it at proton_prefix would create a nested pfx -> . compatibility
|
||||
# symlink; compat_data preserves the exact prefix prepared by Proton 7.
|
||||
"WINEPREFIX": str(paths.compat_data),
|
||||
"GAMEID": "umu-pristontale-eu",
|
||||
"STORE": "none",
|
||||
"PROTONPATH": str(Path(selection.proton_path).parent),
|
||||
"PROTON_VERB": "waitforexitandrun",
|
||||
"PROTONFIXES_DISABLE": "1",
|
||||
"DXVK_STATE_CACHE_PATH": str(paths.dxvk_cache),
|
||||
"WINEDEBUG": "-all",
|
||||
}
|
||||
environment.update(_proton_sync_environment(synchronization))
|
||||
return environment
|
||||
|
||||
|
||||
def _proton_environment(
|
||||
paths: AppPaths,
|
||||
selection: RuntimeSelection,
|
||||
*,
|
||||
synchronization: str = RUNTIME_DEFAULT_SYNC,
|
||||
) -> dict[str, str]:
|
||||
if not selection.steam_root:
|
||||
raise RuntimeErrorPT("Steam root is missing for Proton")
|
||||
return {
|
||||
environment = {
|
||||
"STEAM_COMPAT_CLIENT_INSTALL_PATH": selection.steam_root,
|
||||
"STEAM_COMPAT_DATA_PATH": str(paths.compat_data),
|
||||
"STEAM_COMPAT_INSTALL_PATH": str(paths.runtime_view),
|
||||
@@ -288,6 +642,90 @@ def _proton_environment(
|
||||
"PROTON_LOG": "0",
|
||||
"DXVK_STATE_CACHE_PATH": str(paths.dxvk_cache),
|
||||
}
|
||||
environment.update(_proton_sync_environment(synchronization))
|
||||
return environment
|
||||
|
||||
|
||||
def detect_distribution_id() -> str:
|
||||
"""Return the freedesktop distribution ID without invoking distro tools."""
|
||||
|
||||
try:
|
||||
value = platform.freedesktop_os_release().get("ID", "")
|
||||
except OSError:
|
||||
return ""
|
||||
return value.strip().casefold()
|
||||
|
||||
|
||||
def is_cachyos_host(
|
||||
*,
|
||||
distribution_id: str | None = None,
|
||||
kernel_release: str | None = None,
|
||||
) -> bool:
|
||||
"""Identify CachyOS narrowly for runtime compatibility policy."""
|
||||
|
||||
distribution = detect_distribution_id() if distribution_id is None else distribution_id
|
||||
kernel = platform.release() if kernel_release is None else kernel_release
|
||||
return (
|
||||
CACHYOS_MARKER in distribution.casefold()
|
||||
or CACHYOS_MARKER in kernel.casefold()
|
||||
)
|
||||
|
||||
|
||||
def prefers_current_umu_host(
|
||||
*,
|
||||
distribution_id: str | None = None,
|
||||
kernel_release: str | None = None,
|
||||
) -> bool:
|
||||
"""Use current UMU automatically on rolling Arch-family hosts."""
|
||||
|
||||
distribution = detect_distribution_id() if distribution_id is None else distribution_id
|
||||
return (
|
||||
distribution.strip().casefold() in CURRENT_UMU_DISTRIBUTIONS
|
||||
or is_cachyos_host(
|
||||
distribution_id=distribution,
|
||||
kernel_release=kernel_release,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def resolve_proton_sync_mode(
|
||||
configured: str,
|
||||
*,
|
||||
distribution_id: str | None = None,
|
||||
kernel_release: str | None = None,
|
||||
) -> str:
|
||||
"""Resolve the configured mode, applying the narrow CachyOS compatibility fallback."""
|
||||
|
||||
if configured not in {"auto", "fsync", "esync"}:
|
||||
raise ValueError(f"unsupported Proton synchronization mode: {configured}")
|
||||
if configured != "auto":
|
||||
return configured
|
||||
if is_cachyos_host(
|
||||
distribution_id=distribution_id,
|
||||
kernel_release=kernel_release,
|
||||
):
|
||||
return "esync"
|
||||
return RUNTIME_DEFAULT_SYNC
|
||||
|
||||
|
||||
def _proton_sync_environment(synchronization: str) -> dict[str, str]:
|
||||
if synchronization == RUNTIME_DEFAULT_SYNC:
|
||||
return {}
|
||||
if synchronization == "esync":
|
||||
return {
|
||||
"PROTON_NO_FSYNC": "1",
|
||||
"WINEFSYNC": "0",
|
||||
"PROTON_NO_ESYNC": "0",
|
||||
"WINEESYNC": "1",
|
||||
}
|
||||
if synchronization == "fsync":
|
||||
return {
|
||||
"PROTON_NO_FSYNC": "0",
|
||||
"WINEFSYNC": "1",
|
||||
"PROTON_NO_ESYNC": "0",
|
||||
"WINEESYNC": "1",
|
||||
}
|
||||
raise ValueError(f"unsupported resolved Proton synchronization mode: {synchronization}")
|
||||
|
||||
|
||||
def _ensure_proton_game_link(paths: AppPaths) -> None:
|
||||
@@ -305,15 +743,27 @@ def _ensure_proton_game_link(paths: AppPaths) -> None:
|
||||
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:
|
||||
def _steam_root_for_proton(
|
||||
proton: Path,
|
||||
environment: Mapping[str, str] | None = None,
|
||||
) -> Path:
|
||||
steamapps = next(
|
||||
(parent for parent in proton.parents if parent.name.casefold() == "steamapps"),
|
||||
None,
|
||||
)
|
||||
if steamapps is None:
|
||||
raise RuntimeErrorPT(
|
||||
"configured Proton path must be inside a Steam steamapps directory"
|
||||
) from exc
|
||||
return Path(*parts[:index])
|
||||
)
|
||||
library_root = steamapps.parent.resolve()
|
||||
env = os.environ if environment is None else environment
|
||||
for steam_root in _steam_client_roots(env):
|
||||
if library_root in _steam_library_roots(steam_root):
|
||||
return steam_root
|
||||
# Preserve support for an otherwise valid, manually managed Steam layout.
|
||||
# Without a registered client root, the library root is the best available
|
||||
# compatibility value and matches the launcher's historical behaviour.
|
||||
return library_root
|
||||
|
||||
|
||||
def native_d3d_ready(prefix: Path) -> bool:
|
||||
@@ -348,21 +798,23 @@ def prepare_proton7_winetricks(
|
||||
dry_run: bool = False,
|
||||
) -> Path:
|
||||
try:
|
||||
content = source.read_text(encoding="utf-8")
|
||||
content = source.read_bytes()
|
||||
except OSError as exc:
|
||||
raise RuntimeErrorPT(f"could not read Winetricks script {source}: {exc}") from exc
|
||||
if WINETRICKS_CODEPAGE_PROBE not in content:
|
||||
probe = WINETRICKS_CODEPAGE_PROBE.encode("utf-8")
|
||||
replacement = WINETRICKS_PROTON7_PROBE.encode("utf-8")
|
||||
if probe not in content:
|
||||
return source
|
||||
if dry_run:
|
||||
return destination
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
patched = content.replace(
|
||||
WINETRICKS_CODEPAGE_PROBE,
|
||||
WINETRICKS_PROTON7_PROBE,
|
||||
probe,
|
||||
replacement,
|
||||
1,
|
||||
)
|
||||
temporary = destination.with_name(f".{destination.name}.{os.getpid()}.tmp")
|
||||
temporary.write_text(patched, encoding="utf-8")
|
||||
temporary.write_bytes(patched)
|
||||
temporary.chmod(source.stat().st_mode & 0o777)
|
||||
temporary.replace(destination)
|
||||
return destination
|
||||
@@ -374,6 +826,69 @@ def _proton_prefix_ready(prefix: Path) -> bool:
|
||||
).exists()
|
||||
|
||||
|
||||
def _game_runtime_ready(paths: AppPaths, selection: RuntimeSelection) -> bool:
|
||||
marker = _read_json(paths.game_runtime_marker)
|
||||
identity = _proton_identity(selection)
|
||||
if marker is None or identity is None:
|
||||
return False
|
||||
if marker.get("configuration_version") != GAME_RUNTIME_CONFIGURATION_VERSION:
|
||||
return False
|
||||
if marker.get("runtime") != asdict(selection):
|
||||
return False
|
||||
if marker.get("proton_identity") != identity:
|
||||
return False
|
||||
if marker.get("native_d3d43") is not True:
|
||||
return False
|
||||
if marker.get("dxvk_d3d9_override") != DXVK_D3D9_OVERRIDE:
|
||||
return False
|
||||
if not _proton_prefix_ready(paths.proton_prefix):
|
||||
return False
|
||||
if not native_d3d_ready(paths.proton_prefix):
|
||||
return False
|
||||
if not _d3d9_override_ready(paths.proton_prefix):
|
||||
return False
|
||||
return _proton_game_link_ready(paths)
|
||||
|
||||
|
||||
def _d3d9_override_ready(prefix: Path) -> bool:
|
||||
try:
|
||||
registry = (prefix / "user.reg").read_text(
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
).casefold()
|
||||
except OSError:
|
||||
return False
|
||||
return f'"d3d9"="{DXVK_D3D9_OVERRIDE}"' in registry
|
||||
|
||||
|
||||
def _proton_game_link_ready(paths: AppPaths) -> bool:
|
||||
link = paths.proton_prefix / "drive_c/Program Files (x86)/Pristontale EU"
|
||||
if not link.is_symlink():
|
||||
return False
|
||||
try:
|
||||
return link.resolve(strict=True) == paths.runtime_view.resolve(strict=True)
|
||||
except (OSError, RuntimeError):
|
||||
return False
|
||||
|
||||
|
||||
def _proton_identity(selection: RuntimeSelection) -> dict[str, str] | None:
|
||||
if not selection.proton_path:
|
||||
return None
|
||||
proton = Path(selection.proton_path)
|
||||
identity_paths = {
|
||||
"proton": proton,
|
||||
"version": proton.parent / "version",
|
||||
"toolmanifest": proton.parent / "toolmanifest.vdf",
|
||||
}
|
||||
identity: dict[str, str] = {}
|
||||
for name, path in identity_paths.items():
|
||||
try:
|
||||
identity[name] = _sha256(path)
|
||||
except OSError:
|
||||
return None
|
||||
return identity
|
||||
|
||||
|
||||
def _find_proton_wine_tools(proton: Path) -> tuple[Path, Path] | None:
|
||||
for directory in (proton.parent / "files/bin", proton.parent / "dist/bin"):
|
||||
wine = directory / "wine"
|
||||
@@ -388,3 +903,19 @@ def _write_json(path: Path, value: object) -> None:
|
||||
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
||||
temporary.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
temporary.replace(path)
|
||||
|
||||
|
||||
def _read_json(path: Path) -> dict[str, object] | None:
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeError, json.JSONDecodeError):
|
||||
return None
|
||||
return value if isinstance(value, dict) else None
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
@@ -45,6 +45,7 @@ UPDATE_ARCHIVE_PREFIX = "pristontale-eu-linux-update-v"
|
||||
_VERSION_PATTERN = re.compile(r"(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\Z")
|
||||
_SHA256_PATTERN = re.compile(r"[0-9a-f]{64}\Z")
|
||||
_ALLOWED_RELEASE_ROOTS = {"ptlinux", "ptdiag", "release.json"}
|
||||
UPDATE_CHECK_CACHE_SCHEMA = 1
|
||||
|
||||
|
||||
class LauncherUpdateError(RuntimeError):
|
||||
@@ -84,7 +85,20 @@ class LauncherUpdateResult:
|
||||
previous_release: str | None
|
||||
archive: str | None
|
||||
archive_sha256: str | None
|
||||
restarted_required: bool
|
||||
restart_required: bool
|
||||
state_recorded: bool
|
||||
warning: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LauncherUpdateVerification:
|
||||
current_version: str
|
||||
release_version: str
|
||||
manifest_url: str
|
||||
archive_url: str
|
||||
archive_size: int
|
||||
archive_sha256: str
|
||||
payload_files: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -143,6 +157,89 @@ def check_for_launcher_update(
|
||||
)
|
||||
_verify_manifest_signature(manifest_bytes, signature_bytes, key)
|
||||
manifest = parse_update_manifest(manifest_bytes)
|
||||
return _evaluate_launcher_update(manifest, current_version, url)
|
||||
|
||||
|
||||
def read_cached_launcher_update_check(
|
||||
cache_path: Path,
|
||||
*,
|
||||
current_version: str = __version__,
|
||||
manifest_url: str | None = None,
|
||||
public_key_path: Path | None = None,
|
||||
environment: Mapping[str, str] | None = None,
|
||||
) -> LauncherUpdateCheck | None:
|
||||
"""Read a display-only result from the last authenticated live check."""
|
||||
|
||||
url = manifest_url or configured_manifest_url(environment)
|
||||
key = public_key_path or configured_public_key_path(environment)
|
||||
try:
|
||||
_parse_version(current_version, "current launcher version")
|
||||
_require_https_url(url, "update manifest URL")
|
||||
key_sha256 = _sha256_file(key.expanduser().resolve())
|
||||
value = json.loads(cache_path.read_text(encoding="utf-8"))
|
||||
except (
|
||||
OSError,
|
||||
UnicodeError,
|
||||
json.JSONDecodeError,
|
||||
LauncherUpdateError,
|
||||
RuntimeError,
|
||||
):
|
||||
return None
|
||||
if not isinstance(value, dict):
|
||||
return None
|
||||
if value.get("schema") != UPDATE_CHECK_CACHE_SCHEMA:
|
||||
return None
|
||||
if value.get("current_version") != current_version:
|
||||
return None
|
||||
if value.get("manifest_url") != url:
|
||||
return None
|
||||
if value.get("public_key_sha256") != key_sha256:
|
||||
return None
|
||||
manifest_value = value.get("manifest")
|
||||
if not isinstance(manifest_value, dict):
|
||||
return None
|
||||
try:
|
||||
manifest = parse_update_manifest(
|
||||
(json.dumps(manifest_value, sort_keys=True) + "\n").encode("utf-8")
|
||||
)
|
||||
except (LauncherUpdateError, TypeError, ValueError):
|
||||
return None
|
||||
return _evaluate_launcher_update(manifest, current_version, url)
|
||||
|
||||
|
||||
def cache_launcher_update_check(
|
||||
cache_path: Path,
|
||||
check: LauncherUpdateCheck,
|
||||
*,
|
||||
public_key_path: Path | None = None,
|
||||
environment: Mapping[str, str] | None = None,
|
||||
) -> bool:
|
||||
"""Cache an authenticated result for startup display, never for installation."""
|
||||
|
||||
key = public_key_path or configured_public_key_path(environment)
|
||||
try:
|
||||
key_sha256 = _sha256_file(key.expanduser().resolve())
|
||||
_write_json(
|
||||
cache_path,
|
||||
{
|
||||
"schema": UPDATE_CHECK_CACHE_SCHEMA,
|
||||
"checked_at": datetime.now().astimezone().isoformat(),
|
||||
"current_version": check.current_version,
|
||||
"manifest_url": check.manifest_url,
|
||||
"public_key_sha256": key_sha256,
|
||||
"manifest": asdict(check.manifest),
|
||||
},
|
||||
)
|
||||
except (OSError, RuntimeError):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _evaluate_launcher_update(
|
||||
manifest: UpdateManifest,
|
||||
current_version: str,
|
||||
manifest_url: str,
|
||||
) -> LauncherUpdateCheck:
|
||||
current = _parse_version(current_version, "current launcher version")
|
||||
latest = _parse_version(manifest.version, "manifest version")
|
||||
minimum = _parse_version(
|
||||
@@ -170,7 +267,7 @@ def check_for_launcher_update(
|
||||
available=available,
|
||||
supported=supported,
|
||||
reason=reason,
|
||||
manifest_url=url,
|
||||
manifest_url=manifest_url,
|
||||
manifest=manifest,
|
||||
)
|
||||
|
||||
@@ -178,6 +275,7 @@ def check_for_launcher_update(
|
||||
def install_launcher_update(
|
||||
paths: AppPaths,
|
||||
*,
|
||||
current_version: str = __version__,
|
||||
manifest_url: str | None = None,
|
||||
public_key_path: Path | None = None,
|
||||
environment: Mapping[str, str] | None = None,
|
||||
@@ -186,6 +284,7 @@ def install_launcher_update(
|
||||
"""Authenticate, stage, validate, and atomically activate one update."""
|
||||
|
||||
check = check_for_launcher_update(
|
||||
current_version=current_version,
|
||||
manifest_url=manifest_url,
|
||||
public_key_path=public_key_path,
|
||||
environment=environment,
|
||||
@@ -200,7 +299,9 @@ def install_launcher_update(
|
||||
previous_release=None,
|
||||
archive=None,
|
||||
archive_sha256=None,
|
||||
restarted_required=False,
|
||||
restart_required=False,
|
||||
state_recorded=False,
|
||||
warning=None,
|
||||
)
|
||||
if not check.supported:
|
||||
raise LauncherUpdateError(check.reason)
|
||||
@@ -215,20 +316,26 @@ def install_launcher_update(
|
||||
)
|
||||
release_directory = _stage_and_install_release(paths, archive, check.manifest)
|
||||
previous = _activate_release(paths, release_directory)
|
||||
_write_json(
|
||||
paths.launcher_update_state,
|
||||
{
|
||||
"schema": 1,
|
||||
"updated_at": datetime.now().astimezone().isoformat(),
|
||||
"from_version": check.current_version,
|
||||
"to_version": check.manifest.version,
|
||||
"manifest_url": check.manifest_url,
|
||||
"archive_url": check.manifest.archive_url,
|
||||
"archive_sha256": check.manifest.archive_sha256,
|
||||
"release_directory": str(release_directory),
|
||||
"previous_release": str(previous) if previous else None,
|
||||
},
|
||||
)
|
||||
state_warning = None
|
||||
try:
|
||||
_write_json(
|
||||
paths.launcher_update_state,
|
||||
{
|
||||
"schema": 1,
|
||||
"updated_at": datetime.now().astimezone().isoformat(),
|
||||
"from_version": check.current_version,
|
||||
"to_version": check.manifest.version,
|
||||
"manifest_url": check.manifest_url,
|
||||
"archive_url": check.manifest.archive_url,
|
||||
"archive_sha256": check.manifest.archive_sha256,
|
||||
"release_directory": str(release_directory),
|
||||
"previous_release": str(previous) if previous else None,
|
||||
},
|
||||
)
|
||||
except OSError as exc:
|
||||
# Activation is the durable user-facing operation. A diagnostics-only
|
||||
# state record must not strand the dashboard on the old release.
|
||||
state_warning = f"launcher updated, but update history could not be saved: {exc}"
|
||||
return LauncherUpdateResult(
|
||||
changed=True,
|
||||
current_version=check.current_version,
|
||||
@@ -237,7 +344,57 @@ def install_launcher_update(
|
||||
previous_release=str(previous) if previous else None,
|
||||
archive=str(archive),
|
||||
archive_sha256=check.manifest.archive_sha256,
|
||||
restarted_required=True,
|
||||
restart_required=True,
|
||||
state_recorded=state_warning is None,
|
||||
warning=state_warning,
|
||||
)
|
||||
|
||||
|
||||
def verify_launcher_update_channel(
|
||||
*,
|
||||
current_version: str = __version__,
|
||||
manifest_url: str | None = None,
|
||||
public_key_path: Path | None = None,
|
||||
environment: Mapping[str, str] | None = None,
|
||||
timeout_seconds: float = 60.0,
|
||||
) -> LauncherUpdateVerification:
|
||||
"""Authenticate, download, extract, and import a release without activating it."""
|
||||
|
||||
check = check_for_launcher_update(
|
||||
current_version=current_version,
|
||||
manifest_url=manifest_url,
|
||||
public_key_path=public_key_path,
|
||||
environment=environment,
|
||||
timeout_seconds=min(timeout_seconds, 30.0),
|
||||
)
|
||||
with tempfile.TemporaryDirectory(prefix="ptlinux-update-verification-") as temporary:
|
||||
root = Path(temporary)
|
||||
archive = root / f"{UPDATE_ARCHIVE_PREFIX}{check.manifest.version}.tar.gz"
|
||||
_download_archive(
|
||||
check.manifest,
|
||||
archive,
|
||||
timeout_seconds=timeout_seconds,
|
||||
)
|
||||
staging = root / "release"
|
||||
staging.mkdir(mode=0o700)
|
||||
_extract_release_archive(archive, staging, check.manifest.version)
|
||||
staged_version = _validate_release_directory(staging)
|
||||
if staged_version != check.manifest.version:
|
||||
raise LauncherUpdateError(
|
||||
f"verified launcher version is {staged_version}, "
|
||||
f"expected {check.manifest.version}"
|
||||
)
|
||||
_self_check_release(staging, check.manifest.version)
|
||||
_validate_release_directory(staging)
|
||||
payload_files = sum(1 for item in staging.rglob("*") if item.is_file())
|
||||
return LauncherUpdateVerification(
|
||||
current_version=current_version,
|
||||
release_version=check.manifest.version,
|
||||
manifest_url=check.manifest_url,
|
||||
archive_url=check.manifest.archive_url,
|
||||
archive_size=check.manifest.archive_size,
|
||||
archive_sha256=check.manifest.archive_sha256,
|
||||
payload_files=payload_files,
|
||||
)
|
||||
|
||||
|
||||
@@ -313,6 +470,7 @@ def parse_update_manifest(data: bytes) -> UpdateManifest:
|
||||
f"update archive size must be between 1 and {MAX_ARCHIVE_BYTES} bytes"
|
||||
)
|
||||
published_at = _required_string(value, "published_at")
|
||||
_parse_published_at(published_at)
|
||||
notes_value = value.get("notes_url")
|
||||
if notes_value is not None and not isinstance(notes_value, str):
|
||||
raise LauncherUpdateError("notes_url must be a string or null")
|
||||
@@ -344,6 +502,8 @@ def _fetch_small_file(
|
||||
"User-Agent": USER_AGENT,
|
||||
"Accept": "application/octet-stream, application/json",
|
||||
"Accept-Encoding": "identity",
|
||||
"Cache-Control": "no-cache",
|
||||
"Pragma": "no-cache",
|
||||
},
|
||||
method="GET",
|
||||
)
|
||||
@@ -354,7 +514,13 @@ def _fetch_small_file(
|
||||
except (URLError, OSError) as exc:
|
||||
raise LauncherUpdateError(f"{label} request failed: {exc}") from exc
|
||||
with response:
|
||||
_require_https_url(response.geturl(), f"final {label} URL")
|
||||
final_url = response.geturl()
|
||||
_require_https_url(final_url, f"final {label} URL")
|
||||
if _resource_url(final_url) != _resource_url(url):
|
||||
raise LauncherUpdateError(
|
||||
f"{label} redirected unexpectedly to {final_url}; "
|
||||
"the launcher update channel must be publicly readable"
|
||||
)
|
||||
length = _content_length(response.headers.get("Content-Length"), label)
|
||||
if length is not None and length > maximum_bytes:
|
||||
raise LauncherUpdateError(f"{label} exceeds its size limit")
|
||||
@@ -363,6 +529,8 @@ def _fetch_small_file(
|
||||
raise LauncherUpdateError(f"{label} exceeds its size limit")
|
||||
if length is not None and len(data) != length:
|
||||
raise LauncherUpdateError(f"{label} response was incomplete")
|
||||
if not data:
|
||||
raise LauncherUpdateError(f"{label} response was empty")
|
||||
return data
|
||||
|
||||
|
||||
@@ -399,12 +567,14 @@ def _verify_manifest_signature(
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=10,
|
||||
check=False,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
raise LauncherUpdateError(f"could not verify update signature: {exc}") from exc
|
||||
if result.returncode != 0 or "Verified OK" not in result.stdout:
|
||||
if result.returncode != 0:
|
||||
raise LauncherUpdateError("launcher update manifest signature is invalid")
|
||||
|
||||
|
||||
@@ -501,6 +671,7 @@ def _stage_and_install_release(
|
||||
f"staged launcher version is {staged_version}, expected {manifest.version}"
|
||||
)
|
||||
_self_check_release(staging, manifest.version)
|
||||
_validate_release_directory(staging)
|
||||
staging.chmod(0o755)
|
||||
destination = paths.launcher_releases / manifest.version
|
||||
if destination.exists() or destination.is_symlink():
|
||||
@@ -639,6 +810,7 @@ def _validate_release_directory(release: Path) -> str:
|
||||
Path("ptlinux") / PUBLIC_KEY_FILENAME,
|
||||
Path("ptdiag/__init__.py"),
|
||||
Path("ptdiag/assbin.py"),
|
||||
Path("ptdiag/logs.py"),
|
||||
):
|
||||
if not (release / required).is_file():
|
||||
raise LauncherUpdateError(f"launcher release is missing {required}")
|
||||
@@ -654,9 +826,11 @@ def _self_check_release(release: Path, expected_version: str) -> None:
|
||||
)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-I", "-c", check, str(release), expected_version],
|
||||
[sys.executable, "-I", "-B", "-c", check, str(release), expected_version],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=15,
|
||||
check=False,
|
||||
)
|
||||
@@ -768,6 +942,13 @@ def _signature_url(manifest_url: str) -> str:
|
||||
return urlunsplit((parts.scheme, parts.netloc, parts.path + ".sig", parts.query, ""))
|
||||
|
||||
|
||||
def _resource_url(value: str) -> str:
|
||||
parts = urlsplit(value)
|
||||
return urlunsplit(
|
||||
(parts.scheme.casefold(), parts.netloc.casefold(), parts.path, parts.query, "")
|
||||
)
|
||||
|
||||
|
||||
def _require_https_url(value: str, label: str) -> None:
|
||||
parts = urlsplit(value)
|
||||
if (
|
||||
@@ -787,6 +968,18 @@ def _parse_version(value: str, label: str) -> tuple[int, int, int]:
|
||||
return int(major), int(minor), int(patch)
|
||||
|
||||
|
||||
def _parse_published_at(value: str) -> datetime:
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError as exc:
|
||||
raise LauncherUpdateError(
|
||||
f"manifest published_at must be an ISO-8601 timestamp: {value!r}"
|
||||
) from exc
|
||||
if parsed.tzinfo is None or parsed.utcoffset() is None:
|
||||
raise LauncherUpdateError("manifest published_at must include a timezone")
|
||||
return parsed
|
||||
|
||||
|
||||
def _required_string(value: dict[str, object], key: str) -> str:
|
||||
item = value.get(key)
|
||||
if not isinstance(item, str) or not item:
|
||||
|
||||
157
ptlinux/server_status.py
Normal file
157
ptlinux/server_status.py
Normal file
@@ -0,0 +1,157 @@
|
||||
"""Official realm health used by the native launcher dashboard."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
from typing import Callable, Mapping, Protocol
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from . import __version__
|
||||
|
||||
|
||||
# This is the same publisher endpoint embedded in Windows launcher 1.0.0.64.
|
||||
SERVER_STATUS_ENDPOINT = (
|
||||
"https://pristontale.eu/api/pristontale/developer"
|
||||
"?api_key=d9OlDSvworitU4ajDiH6dU6cxXApc4M0"
|
||||
)
|
||||
MAX_SERVER_STATUS_BYTES = 1024 * 1024
|
||||
|
||||
|
||||
class ServerStatusError(RuntimeError):
|
||||
"""Raised when the official status response cannot be interpreted safely."""
|
||||
|
||||
|
||||
class ServerStatusResponse(Protocol):
|
||||
def read(self, size: int = -1) -> bytes: ...
|
||||
|
||||
def __enter__(self) -> ServerStatusResponse: ...
|
||||
|
||||
def __exit__(
|
||||
self,
|
||||
exception_type: type[BaseException] | None,
|
||||
exception: BaseException | None,
|
||||
traceback: object | None,
|
||||
) -> bool | None: ...
|
||||
|
||||
|
||||
ServerStatusOpener = Callable[..., ServerStatusResponse]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RealmServerStatus:
|
||||
name: str
|
||||
online: bool | None
|
||||
players_online: int | None
|
||||
login_online: bool | None = None
|
||||
game_online: bool | None = None
|
||||
|
||||
|
||||
def fetch_server_status(
|
||||
*,
|
||||
timeout_seconds: float = 10.0,
|
||||
opener: ServerStatusOpener = urlopen,
|
||||
) -> tuple[RealmServerStatus, ...]:
|
||||
request = Request(
|
||||
SERVER_STATUS_ENDPOINT,
|
||||
headers={"User-Agent": f"PT-Linux/{__version__} (+https://pristontale.eu/)"},
|
||||
method="GET",
|
||||
)
|
||||
try:
|
||||
response = opener(request, timeout=timeout_seconds)
|
||||
with response:
|
||||
payload = response.read(MAX_SERVER_STATUS_BYTES + 1)
|
||||
except OSError as exc:
|
||||
raise ServerStatusError("official server status is unavailable") from exc
|
||||
if len(payload) > MAX_SERVER_STATUS_BYTES:
|
||||
raise ServerStatusError("official server status exceeds the 1 MiB limit")
|
||||
return parse_server_status(payload)
|
||||
|
||||
|
||||
def parse_server_status(payload: bytes) -> tuple[RealmServerStatus, ...]:
|
||||
try:
|
||||
value = json.loads(payload.decode("utf-8-sig"))
|
||||
except (UnicodeError, json.JSONDecodeError) as exc:
|
||||
raise ServerStatusError("official server status is not valid JSON") from exc
|
||||
# Historical versions returned an array containing the status object. The
|
||||
# current endpoint returns that object directly.
|
||||
if isinstance(value, list) and value:
|
||||
value = value[0]
|
||||
if not isinstance(value, dict):
|
||||
raise ServerStatusError("official server status is not a realm object")
|
||||
|
||||
statuses: list[RealmServerStatus] = []
|
||||
for raw_name, raw_components in value.items():
|
||||
if not isinstance(raw_name, str) or raw_name.casefold() == "website":
|
||||
continue
|
||||
name = raw_name.strip()
|
||||
if not name or len(name) > 64 or not isinstance(raw_components, Mapping):
|
||||
continue
|
||||
status = _parse_realm(name, raw_components)
|
||||
if status is not None:
|
||||
statuses.append(status)
|
||||
if not statuses:
|
||||
raise ServerStatusError("official server status contains no realms")
|
||||
return tuple(statuses)
|
||||
|
||||
|
||||
def _parse_realm(
|
||||
name: str,
|
||||
components: Mapping[object, object],
|
||||
) -> RealmServerStatus | None:
|
||||
service_states: list[bool] = []
|
||||
login_online: bool | None = None
|
||||
game_online: bool | None = None
|
||||
players_online: int | None = None
|
||||
for raw_key, raw_value in components.items():
|
||||
if not isinstance(raw_key, str):
|
||||
continue
|
||||
key = raw_key.casefold()
|
||||
if key == "users.online":
|
||||
players_online = _non_negative_integer(raw_value)
|
||||
continue
|
||||
if not key.startswith("server."):
|
||||
continue
|
||||
state = _online_state(raw_value)
|
||||
if state is None:
|
||||
continue
|
||||
service_states.append(state)
|
||||
if "login" in key:
|
||||
login_online = state
|
||||
elif "game" in key:
|
||||
game_online = state
|
||||
if not service_states and players_online is None:
|
||||
return None
|
||||
online = all(service_states) if service_states else None
|
||||
display_name = name[0].upper() + name[1:]
|
||||
return RealmServerStatus(
|
||||
name=display_name,
|
||||
online=online,
|
||||
players_online=players_online,
|
||||
login_online=login_online,
|
||||
game_online=game_online,
|
||||
)
|
||||
|
||||
|
||||
def _online_state(value: object) -> bool | None:
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, (int, float)):
|
||||
return value > 0
|
||||
if isinstance(value, str):
|
||||
text = value.strip().casefold()
|
||||
if text in {"online", "up", "true", "1"}:
|
||||
return True
|
||||
if text in {"offline", "down", "false", "0"}:
|
||||
return False
|
||||
return None
|
||||
|
||||
|
||||
def _non_negative_integer(value: object) -> int | None:
|
||||
if isinstance(value, bool):
|
||||
return None
|
||||
if isinstance(value, int) and value >= 0:
|
||||
return value
|
||||
if isinstance(value, str) and value.isascii() and value.isdecimal():
|
||||
return int(value)
|
||||
return None
|
||||
@@ -2,16 +2,24 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
from dataclasses import asdict, dataclass, fields, replace
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
import threading
|
||||
from typing import Any
|
||||
|
||||
from .locking import launcher_operation_lock
|
||||
from .paths import AppPaths
|
||||
|
||||
|
||||
SETTINGS_SCHEMA = 1
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Settings:
|
||||
realm: str = "Babel"
|
||||
width: int = 1600
|
||||
height: int = 900
|
||||
output_width: int = 1600
|
||||
@@ -19,9 +27,11 @@ class Settings:
|
||||
refresh_rate: int = 60
|
||||
fullscreen: bool = False
|
||||
borderless: bool = True
|
||||
suppress_unresponsive_warnings: bool = True
|
||||
scaler: str = "fit"
|
||||
filter: str = "linear"
|
||||
runtime: str = "auto"
|
||||
proton_sync: str = "auto"
|
||||
proton_path: str | None = None
|
||||
game_source: str | None = None
|
||||
|
||||
@@ -31,15 +41,100 @@ class Settings:
|
||||
|
||||
|
||||
def load_settings(paths: AppPaths) -> Settings:
|
||||
try:
|
||||
value = json.loads(paths.settings_file.read_text(encoding="utf-8"))
|
||||
except FileNotFoundError:
|
||||
value = _read_settings_object(paths.settings_file)
|
||||
if value is None:
|
||||
return Settings()
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise ValueError(f"invalid settings file {paths.settings_file}: {exc}") from exc
|
||||
return _settings_from_object(value)
|
||||
|
||||
|
||||
def save_settings(paths: AppPaths, settings: Settings) -> Settings:
|
||||
"""Atomically save a complete settings snapshot.
|
||||
|
||||
Unknown fields from a newer launcher are retained when the existing file is
|
||||
valid. Callers editing only part of the configuration should use
|
||||
``update_settings`` so an old in-memory snapshot cannot undo another save.
|
||||
"""
|
||||
|
||||
_validate(settings)
|
||||
paths.config.mkdir(parents=True, exist_ok=True)
|
||||
with launcher_operation_lock(paths.settings_lock, "settings-save"):
|
||||
existing = _read_settings_object(paths.settings_file)
|
||||
_write_settings(paths, settings, existing)
|
||||
return settings
|
||||
|
||||
|
||||
def update_settings(paths: AppPaths, **changes: Any) -> Settings:
|
||||
"""Merge selected settings into the newest on-disk configuration."""
|
||||
|
||||
unknown = set(changes).difference(_SETTINGS_FIELD_NAMES)
|
||||
if unknown:
|
||||
names = ", ".join(sorted(unknown))
|
||||
raise ValueError(f"unsupported launcher setting: {names}")
|
||||
paths.config.mkdir(parents=True, exist_ok=True)
|
||||
with launcher_operation_lock(paths.settings_lock, "settings-update"):
|
||||
existing = _read_settings_object(paths.settings_file)
|
||||
current = (
|
||||
_settings_from_object(existing)
|
||||
if existing is not None
|
||||
else Settings()
|
||||
)
|
||||
updated = replace(current, **changes)
|
||||
_validate(updated)
|
||||
_write_settings(paths, updated, existing)
|
||||
return updated
|
||||
|
||||
|
||||
def ensure_settings(paths: AppPaths) -> Settings:
|
||||
"""Create settings once and materialize defaults added by newer releases."""
|
||||
|
||||
paths.config.mkdir(parents=True, exist_ok=True)
|
||||
with launcher_operation_lock(paths.settings_lock, "settings-migrate"):
|
||||
existing = _read_settings_object(paths.settings_file)
|
||||
if existing is None:
|
||||
settings = Settings()
|
||||
_write_settings(paths, settings, None)
|
||||
return settings
|
||||
settings = _settings_from_object(existing)
|
||||
if _settings_need_migration(existing):
|
||||
_write_settings(paths, settings, existing)
|
||||
return settings
|
||||
|
||||
|
||||
def reset_settings(paths: AppPaths) -> Settings:
|
||||
"""Replace all launcher settings with the documented defaults."""
|
||||
|
||||
settings = Settings()
|
||||
paths.config.mkdir(parents=True, exist_ok=True)
|
||||
with launcher_operation_lock(paths.settings_lock, "settings-reset"):
|
||||
_write_settings(paths, settings, None)
|
||||
return settings
|
||||
|
||||
|
||||
def _read_settings_object(path: Path) -> dict[str, object] | None:
|
||||
try:
|
||||
content = path.read_bytes()
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except OSError as exc:
|
||||
raise ValueError(f"invalid settings file {path}: {exc}") from exc
|
||||
try:
|
||||
value = json.loads(_decode_settings(content))
|
||||
except (UnicodeError, json.JSONDecodeError) as exc:
|
||||
raise ValueError(f"invalid settings file {path}: {exc}") from exc
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError(f"settings file must contain an object: {paths.settings_file}")
|
||||
raise ValueError(f"settings file must contain an object: {path}")
|
||||
schema = value.get("schema")
|
||||
if schema is not None:
|
||||
if isinstance(schema, bool) or not isinstance(schema, int):
|
||||
raise ValueError(f"settings schema must be an integer: {path}")
|
||||
if schema != SETTINGS_SCHEMA:
|
||||
raise ValueError(f"unsupported settings schema {schema}: {path}")
|
||||
return value
|
||||
|
||||
|
||||
def _settings_from_object(value: dict[str, object]) -> Settings:
|
||||
settings = Settings(
|
||||
realm=_string(value, "realm", "Babel").strip(),
|
||||
width=_integer(value, "width", 1600),
|
||||
height=_integer(value, "height", 900),
|
||||
output_width=_integer(value, "output_width", 1600),
|
||||
@@ -47,9 +142,15 @@ def load_settings(paths: AppPaths) -> Settings:
|
||||
refresh_rate=_integer(value, "refresh_rate", 60),
|
||||
fullscreen=_boolean(value, "fullscreen", False),
|
||||
borderless=_boolean(value, "borderless", True),
|
||||
suppress_unresponsive_warnings=_boolean(
|
||||
value,
|
||||
"suppress_unresponsive_warnings",
|
||||
True,
|
||||
),
|
||||
scaler=_string(value, "scaler", "fit"),
|
||||
filter=_string(value, "filter", "linear"),
|
||||
runtime=_string(value, "runtime", "auto"),
|
||||
proton_sync=_string(value, "proton_sync", "auto"),
|
||||
proton_path=_optional_string(value, "proton_path"),
|
||||
game_source=_optional_string(value, "game_source"),
|
||||
)
|
||||
@@ -57,28 +158,71 @@ def load_settings(paths: AppPaths) -> Settings:
|
||||
return settings
|
||||
|
||||
|
||||
def save_settings(paths: AppPaths, settings: Settings) -> None:
|
||||
_validate(settings)
|
||||
paths.config.mkdir(parents=True, exist_ok=True)
|
||||
def _decode_settings(content: bytes) -> str:
|
||||
"""Accept native UTF-8 plus settings saved by common Windows editors."""
|
||||
|
||||
try:
|
||||
return content.decode("utf-8-sig")
|
||||
except UnicodeDecodeError as utf8_error:
|
||||
try:
|
||||
return content.decode("cp1252")
|
||||
except UnicodeDecodeError:
|
||||
raise utf8_error
|
||||
|
||||
|
||||
def _settings_need_migration(value: dict[str, object]) -> bool:
|
||||
return (
|
||||
value.get("schema") != SETTINGS_SCHEMA
|
||||
or not _SETTINGS_FIELD_NAMES.issubset(value)
|
||||
)
|
||||
|
||||
|
||||
def _write_settings(
|
||||
paths: AppPaths,
|
||||
settings: Settings,
|
||||
existing: dict[str, object] | None,
|
||||
) -> None:
|
||||
payload = dict(existing or {})
|
||||
payload.update(asdict(settings))
|
||||
payload["schema"] = SETTINGS_SCHEMA
|
||||
temporary = paths.settings_file.with_name(
|
||||
f".{paths.settings_file.name}.{os.getpid()}.tmp"
|
||||
f".{paths.settings_file.name}.{os.getpid()}.{threading.get_ident()}."
|
||||
f"{secrets.token_hex(8)}.tmp"
|
||||
)
|
||||
temporary.write_text(
|
||||
json.dumps(asdict(settings), indent=2, sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
temporary.replace(paths.settings_file)
|
||||
try:
|
||||
with temporary.open("x", encoding="utf-8", newline="\n") as handle:
|
||||
json.dump(payload, handle, indent=2, sort_keys=True)
|
||||
handle.write("\n")
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
temporary.chmod(0o600)
|
||||
os.replace(temporary, paths.settings_file)
|
||||
_fsync_directory(paths.config)
|
||||
except BaseException:
|
||||
temporary.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
|
||||
def ensure_settings(paths: AppPaths) -> Settings:
|
||||
if paths.settings_file.exists():
|
||||
return load_settings(paths)
|
||||
settings = Settings()
|
||||
save_settings(paths, settings)
|
||||
return settings
|
||||
def _fsync_directory(path: Path) -> None:
|
||||
try:
|
||||
descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
||||
except OSError:
|
||||
return
|
||||
try:
|
||||
os.fsync(descriptor)
|
||||
except OSError:
|
||||
pass
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def _validate(settings: Settings) -> None:
|
||||
if (
|
||||
len(settings.realm) > 64
|
||||
or not settings.realm.strip()
|
||||
or any(ord(character) < 32 for character in settings.realm)
|
||||
):
|
||||
raise ValueError("realm must be a non-empty display name")
|
||||
for name, value in (
|
||||
("width", settings.width),
|
||||
("height", settings.height),
|
||||
@@ -90,11 +234,13 @@ def _validate(settings: Settings) -> None:
|
||||
if settings.refresh_rate < 24 or settings.refresh_rate > 1000:
|
||||
raise ValueError("refresh_rate must be between 24 and 1000")
|
||||
if settings.scaler not in {"auto", "integer", "fit", "fill", "stretch"}:
|
||||
raise ValueError(f"unsupported Gamescope scaler: {settings.scaler}")
|
||||
raise ValueError(f"unsupported legacy scaler setting: {settings.scaler}")
|
||||
if settings.filter not in {"linear", "nearest", "fsr", "nis", "pixel"}:
|
||||
raise ValueError(f"unsupported Gamescope filter: {settings.filter}")
|
||||
if settings.runtime not in {"auto", "proton7", "umu"}:
|
||||
raise ValueError(f"unsupported legacy filter setting: {settings.filter}")
|
||||
if settings.runtime not in {"auto", "proton7", "proton7-container", "umu"}:
|
||||
raise ValueError(f"unsupported runtime selection: {settings.runtime}")
|
||||
if settings.proton_sync not in {"auto", "fsync", "esync"}:
|
||||
raise ValueError(f"unsupported Proton synchronization mode: {settings.proton_sync}")
|
||||
|
||||
|
||||
def _integer(value: dict[str, object], key: str, default: int) -> int:
|
||||
@@ -125,3 +271,6 @@ def _optional_string(value: dict[str, object], key: str) -> str | None:
|
||||
if not isinstance(item, str) or not item:
|
||||
raise ValueError(f"{key} must be null or a non-empty string")
|
||||
return item
|
||||
|
||||
|
||||
_SETTINGS_FIELD_NAMES = frozenset(field.name for field in fields(Settings))
|
||||
|
||||
705
ptlinux/tk_chrome.py
Normal file
705
ptlinux/tk_chrome.py
Normal file
@@ -0,0 +1,705 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Literal
|
||||
|
||||
from .desktop_identity import APPLICATION_WM_CLASS, APPLICATION_WM_INSTANCE
|
||||
|
||||
|
||||
DialogKind = Literal["info", "success", "warning", "error", "question"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DialogPalette:
|
||||
"""Colors and glyph used by one launcher dialog intent."""
|
||||
|
||||
accent: str
|
||||
accent_soft: str
|
||||
glyph: str
|
||||
|
||||
|
||||
_DIALOG_PALETTES: dict[str, DialogPalette] = {
|
||||
"info": DialogPalette("#d98927", "#3a2819", "i"),
|
||||
"success": DialogPalette("#55b878", "#173122", "✓"),
|
||||
"warning": DialogPalette("#e0a248", "#3b2a18", "!"),
|
||||
"error": DialogPalette("#cf604c", "#3a1d19", "×"),
|
||||
"question": DialogPalette("#d98927", "#3a2819", "?"),
|
||||
}
|
||||
|
||||
|
||||
def dialog_palette(kind: DialogKind) -> DialogPalette:
|
||||
"""Return the stable visual palette for a themed launcher dialog."""
|
||||
|
||||
try:
|
||||
return _DIALOG_PALETTES[kind]
|
||||
except KeyError as error:
|
||||
raise ValueError(f"unsupported launcher dialog kind: {kind}") from error
|
||||
|
||||
|
||||
def square_icon_layout(width: int, height: int) -> tuple[int, int, int]:
|
||||
"""Return square canvas size and centered offsets for a window icon."""
|
||||
|
||||
if width <= 0 or height <= 0:
|
||||
raise ValueError("icon dimensions must be positive")
|
||||
size = max(width, height)
|
||||
return size, (size - width) // 2, (size - height) // 2
|
||||
|
||||
|
||||
def icon_subsample_factor(width: int, height: int, max_dimension: int) -> int:
|
||||
"""Return the integer Tk subsample needed to fit an image within a square."""
|
||||
|
||||
if width <= 0 or height <= 0:
|
||||
raise ValueError("icon dimensions must be positive")
|
||||
if max_dimension <= 0:
|
||||
raise ValueError("maximum icon dimension must be positive")
|
||||
largest_dimension = max(width, height)
|
||||
return max(1, (largest_dimension + max_dimension - 1) // max_dimension)
|
||||
|
||||
|
||||
def load_square_window_icon(master: Any, path: Path) -> Any:
|
||||
"""Load a PNG into a square transparent Tk image for desktop shells."""
|
||||
|
||||
import tkinter as tk
|
||||
|
||||
source = tk.PhotoImage(master=master, file=str(path))
|
||||
size, offset_x, offset_y = square_icon_layout(source.width(), source.height())
|
||||
if source.width() == source.height():
|
||||
return source
|
||||
icon = tk.PhotoImage(master=master, width=size, height=size)
|
||||
icon.tk.call(
|
||||
str(icon),
|
||||
"copy",
|
||||
str(source),
|
||||
"-to",
|
||||
offset_x,
|
||||
offset_y,
|
||||
)
|
||||
return icon
|
||||
|
||||
|
||||
def centered_window_geometry(
|
||||
width: int,
|
||||
height: int,
|
||||
*,
|
||||
parent_x: int,
|
||||
parent_y: int,
|
||||
parent_width: int,
|
||||
parent_height: int,
|
||||
) -> str:
|
||||
"""Return Tk geometry centered over a parent window."""
|
||||
|
||||
if width <= 0 or height <= 0:
|
||||
raise ValueError("window dimensions must be positive")
|
||||
x = parent_x + (max(1, parent_width) - width) // 2
|
||||
y = parent_y + (max(1, parent_height) - height) // 2
|
||||
return f"{width}x{height}{_geometry_offset(x)}{_geometry_offset(y)}"
|
||||
|
||||
|
||||
def dragged_window_geometry(
|
||||
pointer_x: int,
|
||||
pointer_y: int,
|
||||
*,
|
||||
offset_x: int,
|
||||
offset_y: int,
|
||||
) -> str:
|
||||
"""Return the Tk position for a pointer-driven custom title bar."""
|
||||
|
||||
return (
|
||||
f"{_geometry_offset(pointer_x - offset_x)}"
|
||||
f"{_geometry_offset(pointer_y - offset_y)}"
|
||||
)
|
||||
|
||||
|
||||
def _geometry_offset(value: int) -> str:
|
||||
return f"+{value}" if value >= 0 else str(value)
|
||||
|
||||
|
||||
def _bind_window_drag(handle: Any, *widgets: Any) -> None:
|
||||
position = [0, 0]
|
||||
|
||||
def begin(event: Any) -> None:
|
||||
position[0] = int(event.x_root) - int(handle.winfo_x())
|
||||
position[1] = int(event.y_root) - int(handle.winfo_y())
|
||||
|
||||
def move(event: Any) -> None:
|
||||
handle.geometry(
|
||||
dragged_window_geometry(
|
||||
int(event.x_root),
|
||||
int(event.y_root),
|
||||
offset_x=position[0],
|
||||
offset_y=position[1],
|
||||
)
|
||||
)
|
||||
|
||||
for widget in widgets:
|
||||
widget.bind("<ButtonPress-1>", begin, add="+")
|
||||
widget.bind("<B1-Motion>", move, add="+")
|
||||
|
||||
|
||||
class DashboardChrome:
|
||||
"""Controller for one frameless launcher window and its controls."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
root: Any,
|
||||
content: Any,
|
||||
close_button: Any,
|
||||
minimize_button: Any,
|
||||
*,
|
||||
managed: bool,
|
||||
identity_configured: bool,
|
||||
allow_close: bool,
|
||||
) -> None:
|
||||
self.root = root
|
||||
self.content = content
|
||||
self.managed = managed
|
||||
self.identity_configured = identity_configured
|
||||
self._close_button = close_button
|
||||
self._minimize_button = minimize_button
|
||||
self._allow_close = allow_close
|
||||
self._close_command: Callable[[], None] = root.destroy
|
||||
if close_button is not None:
|
||||
close_button.configure(command=self.close)
|
||||
if minimize_button is not None:
|
||||
minimize_button.configure(command=self.minimize)
|
||||
root.bind("<Alt-F4>", self._close_from_event, add="+")
|
||||
|
||||
def set_close_command(self, command: Callable[[], None]) -> None:
|
||||
self._close_command = command
|
||||
|
||||
def close(self) -> None:
|
||||
if self._allow_close:
|
||||
self._close_command()
|
||||
|
||||
def minimize(self) -> None:
|
||||
"""Ask the window manager to iconify the managed dashboard."""
|
||||
|
||||
import tkinter as tk
|
||||
|
||||
try:
|
||||
self.root.iconify()
|
||||
except tk.TclError:
|
||||
pass
|
||||
|
||||
def _close_from_event(self, _event: Any) -> str:
|
||||
self.close()
|
||||
return "break"
|
||||
|
||||
|
||||
|
||||
def _configure_x11_window(
|
||||
root: Any,
|
||||
*,
|
||||
resource_name: str,
|
||||
resource_class: str,
|
||||
) -> tuple[bool, bool]:
|
||||
"""Hide the X11 frame and give desktop shells a stable window identity."""
|
||||
|
||||
import ctypes.util
|
||||
|
||||
library_path = ctypes.util.find_library("X11")
|
||||
if library_path is None:
|
||||
return False, False
|
||||
try:
|
||||
x11 = ctypes.CDLL(library_path)
|
||||
except OSError:
|
||||
return False, False
|
||||
|
||||
class XClassHint(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("res_name", ctypes.c_char_p),
|
||||
("res_class", ctypes.c_char_p),
|
||||
]
|
||||
|
||||
x11.XOpenDisplay.argtypes = [ctypes.c_char_p]
|
||||
x11.XOpenDisplay.restype = ctypes.c_void_p
|
||||
x11.XQueryTree.argtypes = [
|
||||
ctypes.c_void_p,
|
||||
ctypes.c_ulong,
|
||||
ctypes.POINTER(ctypes.c_ulong),
|
||||
ctypes.POINTER(ctypes.c_ulong),
|
||||
ctypes.POINTER(ctypes.POINTER(ctypes.c_ulong)),
|
||||
ctypes.POINTER(ctypes.c_uint),
|
||||
]
|
||||
x11.XQueryTree.restype = ctypes.c_int
|
||||
x11.XInternAtom.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_int]
|
||||
x11.XInternAtom.restype = ctypes.c_ulong
|
||||
x11.XChangeProperty.argtypes = [
|
||||
ctypes.c_void_p,
|
||||
ctypes.c_ulong,
|
||||
ctypes.c_ulong,
|
||||
ctypes.c_ulong,
|
||||
ctypes.c_int,
|
||||
ctypes.c_int,
|
||||
ctypes.POINTER(ctypes.c_ubyte),
|
||||
ctypes.c_int,
|
||||
]
|
||||
x11.XChangeProperty.restype = ctypes.c_int
|
||||
x11.XSetClassHint.argtypes = [
|
||||
ctypes.c_void_p,
|
||||
ctypes.c_ulong,
|
||||
ctypes.POINTER(XClassHint),
|
||||
]
|
||||
x11.XSetClassHint.restype = ctypes.c_int
|
||||
x11.XFlush.argtypes = [ctypes.c_void_p]
|
||||
x11.XFlush.restype = ctypes.c_int
|
||||
x11.XFree.argtypes = [ctypes.c_void_p]
|
||||
x11.XFree.restype = ctypes.c_int
|
||||
x11.XCloseDisplay.argtypes = [ctypes.c_void_p]
|
||||
x11.XCloseDisplay.restype = ctypes.c_int
|
||||
root.update_idletasks()
|
||||
display = x11.XOpenDisplay(None)
|
||||
if display is None:
|
||||
return False, False
|
||||
children = ctypes.POINTER(ctypes.c_ulong)()
|
||||
try:
|
||||
root_window = ctypes.c_ulong()
|
||||
wrapper_window = ctypes.c_ulong()
|
||||
child_count = ctypes.c_uint()
|
||||
queried = x11.XQueryTree(
|
||||
display,
|
||||
root.winfo_id(),
|
||||
ctypes.byref(root_window),
|
||||
ctypes.byref(wrapper_window),
|
||||
ctypes.byref(children),
|
||||
ctypes.byref(child_count),
|
||||
)
|
||||
if not queried or not wrapper_window.value:
|
||||
return False, False
|
||||
atom = x11.XInternAtom(display, b"_MOTIF_WM_HINTS", 0)
|
||||
if not atom:
|
||||
return False, False
|
||||
motif_hints_decorations = 1 << 1
|
||||
hints = (ctypes.c_ulong * 5)(motif_hints_decorations, 0, 0, 0, 0)
|
||||
changed = x11.XChangeProperty(
|
||||
display,
|
||||
wrapper_window.value,
|
||||
atom,
|
||||
atom,
|
||||
32,
|
||||
0,
|
||||
ctypes.cast(hints, ctypes.POINTER(ctypes.c_ubyte)),
|
||||
len(hints),
|
||||
)
|
||||
identity = XClassHint(
|
||||
res_name=resource_name.encode("utf-8"),
|
||||
res_class=resource_class.encode("utf-8"),
|
||||
)
|
||||
identity_changed = x11.XSetClassHint(
|
||||
display,
|
||||
wrapper_window.value,
|
||||
ctypes.byref(identity),
|
||||
)
|
||||
x11.XFlush(display)
|
||||
return bool(changed), bool(identity_changed)
|
||||
finally:
|
||||
if children:
|
||||
x11.XFree(children)
|
||||
x11.XCloseDisplay(display)
|
||||
|
||||
|
||||
def create_window_chrome(
|
||||
root: Any,
|
||||
*,
|
||||
title: str,
|
||||
subtitle: str,
|
||||
window_icon: Any | None = None,
|
||||
show_title_bar: bool = True,
|
||||
show_close: bool = True,
|
||||
show_minimize: bool = False,
|
||||
allow_close: bool = True,
|
||||
) -> DashboardChrome:
|
||||
"""Install polished managed chrome around one launcher-owned Tk window."""
|
||||
|
||||
import tkinter as tk
|
||||
|
||||
managed, identity_configured = _configure_x11_window(
|
||||
root,
|
||||
resource_name=APPLICATION_WM_INSTANCE,
|
||||
resource_class=APPLICATION_WM_CLASS,
|
||||
)
|
||||
if not managed:
|
||||
root.overrideredirect(True)
|
||||
border = tk.Frame(
|
||||
root,
|
||||
background="#6a482d",
|
||||
borderwidth=0,
|
||||
highlightthickness=0,
|
||||
)
|
||||
border.pack(fill="both", expand=True)
|
||||
surface = tk.Frame(
|
||||
border,
|
||||
background="#0c0908",
|
||||
borderwidth=0,
|
||||
highlightthickness=0,
|
||||
)
|
||||
surface.pack(fill="both", expand=True, padx=1, pady=1)
|
||||
title_bar = tk.Frame(
|
||||
surface,
|
||||
background="#17100d",
|
||||
height=32,
|
||||
borderwidth=0,
|
||||
highlightthickness=0,
|
||||
cursor="fleur",
|
||||
)
|
||||
if show_title_bar:
|
||||
title_bar.pack(fill="x")
|
||||
title_bar.pack_propagate(False)
|
||||
tk_accent = tk.Frame(title_bar, background="#d98927", width=3)
|
||||
tk_accent.pack(side="left", fill="y")
|
||||
title_icon: Any | None = None
|
||||
if window_icon is not None:
|
||||
try:
|
||||
factor = icon_subsample_factor(
|
||||
window_icon.width(),
|
||||
window_icon.height(),
|
||||
20,
|
||||
)
|
||||
title_icon = window_icon.subsample(factor, factor)
|
||||
except (tk.TclError, ValueError):
|
||||
title_icon = None
|
||||
if title_icon is not None:
|
||||
mark = tk.Label(
|
||||
title_bar,
|
||||
image=title_icon,
|
||||
width=22,
|
||||
height=22,
|
||||
background="#17100d",
|
||||
borderwidth=0,
|
||||
highlightthickness=0,
|
||||
cursor="fleur",
|
||||
)
|
||||
mark.image = title_icon
|
||||
else:
|
||||
mark = tk.Frame(
|
||||
title_bar,
|
||||
width=22,
|
||||
height=22,
|
||||
background="#17100d",
|
||||
borderwidth=0,
|
||||
highlightthickness=0,
|
||||
cursor="fleur",
|
||||
)
|
||||
mark.pack(side="left", padx=(9, 7))
|
||||
identity = tk.Frame(title_bar, background="#17100d", cursor="fleur")
|
||||
identity.pack(side="left", fill="y")
|
||||
title_label = tk.Label(
|
||||
identity,
|
||||
text=title,
|
||||
background="#17100d",
|
||||
foreground="#f2dfbd",
|
||||
font=("Sans", 8, "bold"),
|
||||
cursor="fleur",
|
||||
)
|
||||
title_label.pack(side="left", pady=(0, 1))
|
||||
subtitle_label = tk.Label(
|
||||
identity,
|
||||
text=f" · {subtitle}",
|
||||
background="#17100d",
|
||||
foreground="#8f7b69",
|
||||
font=("Sans", 7),
|
||||
cursor="fleur",
|
||||
)
|
||||
subtitle_label.pack(side="left", pady=(0, 1))
|
||||
close_button: Any = None
|
||||
if show_close:
|
||||
close_button = tk.Button(
|
||||
title_bar,
|
||||
text="×",
|
||||
background="#17100d",
|
||||
foreground="#c9b7a4",
|
||||
activebackground="#a63f2f",
|
||||
activeforeground="#ffffff",
|
||||
font=("Sans", 12),
|
||||
width=4,
|
||||
borderwidth=0,
|
||||
highlightthickness=0,
|
||||
relief="flat",
|
||||
cursor="hand2",
|
||||
takefocus=False,
|
||||
)
|
||||
close_button.pack(side="right", fill="y")
|
||||
minimize_button: Any = None
|
||||
if show_minimize:
|
||||
minimize_button = tk.Button(
|
||||
title_bar,
|
||||
text="—",
|
||||
background="#17100d",
|
||||
foreground="#c9b7a4",
|
||||
activebackground="#312219",
|
||||
activeforeground="#ffffff",
|
||||
font=("Sans", 10, "bold"),
|
||||
width=4,
|
||||
borderwidth=0,
|
||||
highlightthickness=0,
|
||||
relief="flat",
|
||||
cursor="hand2",
|
||||
takefocus=False,
|
||||
)
|
||||
minimize_button.pack(side="right", fill="y")
|
||||
content = tk.Frame(
|
||||
surface,
|
||||
background="#0c0908",
|
||||
borderwidth=0,
|
||||
highlightthickness=0,
|
||||
)
|
||||
content.pack(fill="both", expand=True)
|
||||
if show_title_bar:
|
||||
_bind_window_drag(root, title_bar, mark, identity, title_label, subtitle_label)
|
||||
return DashboardChrome(
|
||||
root,
|
||||
content,
|
||||
close_button,
|
||||
minimize_button,
|
||||
managed=managed,
|
||||
identity_configured=identity_configured,
|
||||
allow_close=allow_close,
|
||||
)
|
||||
|
||||
|
||||
def create_dashboard_chrome(
|
||||
root: Any,
|
||||
*,
|
||||
title: str,
|
||||
subtitle: str,
|
||||
window_icon: Any | None = None,
|
||||
) -> DashboardChrome:
|
||||
"""Install the draggable frame and full controls used by the dashboard."""
|
||||
|
||||
return create_window_chrome(
|
||||
root,
|
||||
title=title,
|
||||
subtitle=subtitle,
|
||||
window_icon=window_icon,
|
||||
show_close=True,
|
||||
show_minimize=True,
|
||||
allow_close=True,
|
||||
)
|
||||
|
||||
|
||||
def show_themed_dialog(
|
||||
parent: Any,
|
||||
*,
|
||||
title: str,
|
||||
message: str,
|
||||
kind: DialogKind = "info",
|
||||
confirm: bool = False,
|
||||
accept_text: str = "OK",
|
||||
cancel_text: str = "CANCEL",
|
||||
window_icon: Any | None = None,
|
||||
) -> bool:
|
||||
"""Show one frameless modal dialog matching the native launcher palette."""
|
||||
|
||||
import tkinter as tk
|
||||
|
||||
palette = dialog_palette(kind)
|
||||
dialog = tk.Toplevel(parent)
|
||||
dialog.withdraw()
|
||||
dialog.title(title)
|
||||
dialog.configure(background=palette.accent)
|
||||
dialog.resizable(False, False)
|
||||
dialog.overrideredirect(True)
|
||||
if window_icon is not None:
|
||||
try:
|
||||
dialog.iconphoto(False, window_icon)
|
||||
except tk.TclError:
|
||||
pass
|
||||
try:
|
||||
dialog.transient(parent)
|
||||
except tk.TclError:
|
||||
pass
|
||||
result = False
|
||||
|
||||
def finish(value: bool) -> None:
|
||||
nonlocal result
|
||||
result = value
|
||||
try:
|
||||
dialog.grab_release()
|
||||
except tk.TclError:
|
||||
pass
|
||||
dialog.destroy()
|
||||
|
||||
border = tk.Frame(dialog, background=palette.accent)
|
||||
border.pack(fill="both", expand=True)
|
||||
surface = tk.Frame(border, background="#191311")
|
||||
surface.pack(fill="both", expand=True, padx=1, pady=1)
|
||||
header = tk.Frame(surface, background="#24170f", height=66)
|
||||
header.pack(fill="x")
|
||||
header.pack_propagate(False)
|
||||
tk_accent = tk.Frame(header, background=palette.accent, width=5)
|
||||
tk_accent.pack(side="left", fill="y")
|
||||
header_copy = tk.Frame(header, background="#24170f")
|
||||
header_copy.pack(side="left", fill="both", expand=True, padx=16, pady=10)
|
||||
eyebrow = tk.Label(
|
||||
header_copy,
|
||||
text="PRISTONTALE EU · LINUX LAUNCHER",
|
||||
background="#24170f",
|
||||
foreground=palette.accent,
|
||||
font=("Sans", 7, "bold"),
|
||||
anchor="w",
|
||||
)
|
||||
eyebrow.pack(fill="x")
|
||||
title_label = tk.Label(
|
||||
header_copy,
|
||||
text=title,
|
||||
background="#24170f",
|
||||
foreground="#fff0d2",
|
||||
font=("Georgia", 15, "bold"),
|
||||
anchor="w",
|
||||
)
|
||||
title_label.pack(fill="x", pady=(1, 0))
|
||||
close_button = tk.Button(
|
||||
header,
|
||||
text="×",
|
||||
command=lambda: finish(False),
|
||||
background="#24170f",
|
||||
foreground="#c9b7a4",
|
||||
activebackground="#a63f2f",
|
||||
activeforeground="#ffffff",
|
||||
font=("Sans", 12),
|
||||
width=4,
|
||||
borderwidth=0,
|
||||
highlightthickness=0,
|
||||
relief="flat",
|
||||
cursor="hand2",
|
||||
takefocus=False,
|
||||
)
|
||||
close_button.pack(side="right", fill="y")
|
||||
body = tk.Frame(surface, background="#191311", padx=20, pady=20)
|
||||
body.pack(fill="both", expand=True)
|
||||
modal_icon: Any | None = None
|
||||
if window_icon is not None:
|
||||
try:
|
||||
factor = icon_subsample_factor(
|
||||
window_icon.width(),
|
||||
window_icon.height(),
|
||||
46,
|
||||
)
|
||||
modal_icon = window_icon.subsample(factor, factor)
|
||||
except (tk.TclError, ValueError):
|
||||
modal_icon = None
|
||||
if modal_icon is not None:
|
||||
icon_host = tk.Frame(
|
||||
body,
|
||||
width=46,
|
||||
height=46,
|
||||
background="#191311",
|
||||
borderwidth=0,
|
||||
highlightthickness=0,
|
||||
)
|
||||
icon_host.pack(side="left", anchor="n", padx=(0, 16))
|
||||
icon_host.pack_propagate(False)
|
||||
icon = tk.Label(
|
||||
icon_host,
|
||||
image=modal_icon,
|
||||
background="#191311",
|
||||
borderwidth=0,
|
||||
highlightthickness=0,
|
||||
)
|
||||
icon.place(relx=0.5, rely=0.5, anchor="center")
|
||||
else:
|
||||
icon = tk.Canvas(
|
||||
body,
|
||||
width=46,
|
||||
height=46,
|
||||
background="#191311",
|
||||
borderwidth=0,
|
||||
highlightthickness=0,
|
||||
)
|
||||
icon.create_oval(
|
||||
3,
|
||||
3,
|
||||
43,
|
||||
43,
|
||||
fill=palette.accent_soft,
|
||||
outline=palette.accent,
|
||||
width=2,
|
||||
)
|
||||
icon.create_text(
|
||||
23,
|
||||
23,
|
||||
text=palette.glyph,
|
||||
fill=palette.accent,
|
||||
font=("Sans", 17, "bold"),
|
||||
)
|
||||
icon.pack(side="left", anchor="n", padx=(0, 16))
|
||||
message_label = tk.Label(
|
||||
body,
|
||||
text=message,
|
||||
background="#191311",
|
||||
foreground="#ded1c4",
|
||||
font=("Sans", 10),
|
||||
justify="left",
|
||||
anchor="w",
|
||||
wraplength=420,
|
||||
)
|
||||
message_label.pack(side="left", fill="both", expand=True, anchor="n")
|
||||
footer = tk.Frame(surface, background="#100d0c", padx=18, pady=12)
|
||||
footer.pack(fill="x")
|
||||
|
||||
def button(
|
||||
container: Any,
|
||||
*,
|
||||
label: str,
|
||||
command: Callable[[], None],
|
||||
primary: bool,
|
||||
) -> Any:
|
||||
background = palette.accent if primary else "#2a201c"
|
||||
foreground = "#17100d" if primary else "#ead8c2"
|
||||
active_background = "#efae57" if primary else "#3a2b25"
|
||||
return tk.Button(
|
||||
container,
|
||||
text=label,
|
||||
command=command,
|
||||
background=background,
|
||||
foreground=foreground,
|
||||
activebackground=active_background,
|
||||
activeforeground="#ffffff" if not primary else "#17100d",
|
||||
font=("Sans", 8, "bold"),
|
||||
padx=16,
|
||||
pady=7,
|
||||
borderwidth=0,
|
||||
highlightthickness=0,
|
||||
relief="flat",
|
||||
cursor="hand2",
|
||||
)
|
||||
|
||||
accept_button = button(
|
||||
footer,
|
||||
label=accept_text,
|
||||
command=lambda: finish(True),
|
||||
primary=True,
|
||||
)
|
||||
accept_button.pack(side="right")
|
||||
if confirm:
|
||||
cancel_button = button(
|
||||
footer,
|
||||
label=cancel_text,
|
||||
command=lambda: finish(False),
|
||||
primary=False,
|
||||
)
|
||||
cancel_button.pack(side="right", padx=(0, 8))
|
||||
_bind_window_drag(dialog, header, header_copy, eyebrow, title_label)
|
||||
dialog.bind("<Escape>", lambda _event: finish(False))
|
||||
dialog.bind("<Return>", lambda _event: finish(True))
|
||||
parent.update_idletasks()
|
||||
dialog.update_idletasks()
|
||||
width = 560
|
||||
height = max(218, dialog.winfo_reqheight())
|
||||
dialog.geometry(
|
||||
centered_window_geometry(
|
||||
width,
|
||||
height,
|
||||
parent_x=parent.winfo_rootx(),
|
||||
parent_y=parent.winfo_rooty(),
|
||||
parent_width=parent.winfo_width(),
|
||||
parent_height=parent.winfo_height(),
|
||||
)
|
||||
)
|
||||
dialog.deiconify()
|
||||
dialog.lift()
|
||||
dialog.grab_set()
|
||||
accept_button.focus_set()
|
||||
parent.wait_window(dialog)
|
||||
return result
|
||||
3241
ptlinux/ui.py
3241
ptlinux/ui.py
File diff suppressed because it is too large
Load Diff
@@ -1,156 +0,0 @@
|
||||
"""Build the verified V21 texture-reclaim runtime DLL from a local game DLL."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import struct
|
||||
|
||||
|
||||
SOURCE_SHA256 = "4b4e5c28e2bfdaa8d36c8341fad27becfd03faf63aa8ea874f5d43c5b1961868"
|
||||
PATCHED_SHA256 = "0be11b06ccf9be747e32b195c8d5980062f446d7d3a0a662ebfe0e602a01c538"
|
||||
TEXT_VA = 0x10001000
|
||||
TEXT_FILE_OFFSET = 0x400
|
||||
ENTRY_VA = 0x107C0B40
|
||||
ENTRY_FILE_OFFSET = TEXT_FILE_OFFSET + (ENTRY_VA - TEXT_VA)
|
||||
ENTRY_ORIGINAL = bytes.fromhex("55 8b ec 6a ff")
|
||||
CAVE_VA = 0x11195CE0
|
||||
CAVE_FILE_OFFSET = TEXT_FILE_OFFSET + (CAVE_VA - TEXT_VA)
|
||||
TEXT_RAW_SIZE = 0x1194E00
|
||||
MAX_HOOK_SIZE = TEXT_FILE_OFFSET + TEXT_RAW_SIZE - CAVE_FILE_OFFSET
|
||||
HOOK = bytes.fromhex(
|
||||
"9c60e8490136ff85c0744a89c38d70088dbe505d010039fe743b80be58030000"
|
||||
"00742a8b068b4c242885c974083b81d41f000074188b4c242c85c974083b81d4"
|
||||
"1f000074085089d9e8a32036ff81c650040000ebc1619d5589e56affe904ae62"
|
||||
"ff"
|
||||
)
|
||||
|
||||
|
||||
class TextureReclaimPatchError(RuntimeError):
|
||||
"""Raised when the exact V21 binary profile cannot be patched safely."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TextureReclaimPatchResult:
|
||||
source: str
|
||||
destination: str
|
||||
source_sha256: str
|
||||
destination_sha256: str
|
||||
entry_va: str
|
||||
cave_va: str
|
||||
hook_size: int
|
||||
old_text_virtual_size: int
|
||||
new_text_virtual_size: int
|
||||
commands: tuple[tuple[str, ...], ...]
|
||||
created_at: str
|
||||
|
||||
|
||||
def build_texture_reclaim_patch(
|
||||
source: Path,
|
||||
destination: Path,
|
||||
) -> TextureReclaimPatchResult:
|
||||
"""Create the V21 patch in a separate runtime cache, never in the game tree."""
|
||||
source = source.expanduser().resolve(strict=True)
|
||||
if _sha256(source) != SOURCE_SHA256:
|
||||
raise TextureReclaimPatchError(
|
||||
"source game.dll does not match the verified V21 profile"
|
||||
)
|
||||
|
||||
hook = HOOK
|
||||
commands: list[tuple[str, ...]] = []
|
||||
if not hook or len(hook) > MAX_HOOK_SIZE:
|
||||
raise TextureReclaimPatchError(
|
||||
f"compiled hook size {len(hook)} exceeds the {MAX_HOOK_SIZE}-byte code cave"
|
||||
)
|
||||
|
||||
image = bytearray(source.read_bytes())
|
||||
if bytes(image[ENTRY_FILE_OFFSET : ENTRY_FILE_OFFSET + len(ENTRY_ORIGINAL)]) != ENTRY_ORIGINAL:
|
||||
raise TextureReclaimPatchError(
|
||||
"V21 map-loader entry bytes do not match the verified profile"
|
||||
)
|
||||
if any(image[CAVE_FILE_OFFSET : CAVE_FILE_OFFSET + len(hook)]):
|
||||
raise TextureReclaimPatchError("V21 code cave is not empty")
|
||||
|
||||
text_header = _find_pe_section(image, b".text")
|
||||
old_virtual_size, text_rva, raw_size, raw_offset = struct.unpack_from(
|
||||
"<IIII", image, text_header + 8
|
||||
)
|
||||
if (text_rva, raw_size, raw_offset) != (
|
||||
TEXT_VA - 0x10000000,
|
||||
TEXT_RAW_SIZE,
|
||||
TEXT_FILE_OFFSET,
|
||||
):
|
||||
raise TextureReclaimPatchError(
|
||||
"V21 executable section layout does not match the verified profile"
|
||||
)
|
||||
new_virtual_size = CAVE_VA - TEXT_VA + len(hook)
|
||||
if new_virtual_size > TEXT_RAW_SIZE:
|
||||
raise TextureReclaimPatchError("compiled hook extends beyond the executable section")
|
||||
if old_virtual_size > new_virtual_size:
|
||||
raise TextureReclaimPatchError(
|
||||
"verified executable section is already larger than the patch target"
|
||||
)
|
||||
|
||||
entry_displacement = CAVE_VA - (ENTRY_VA + 5)
|
||||
image[ENTRY_FILE_OFFSET : ENTRY_FILE_OFFSET + 5] = b"\xe9" + struct.pack(
|
||||
"<i", entry_displacement
|
||||
)
|
||||
image[CAVE_FILE_OFFSET : CAVE_FILE_OFFSET + len(hook)] = hook
|
||||
struct.pack_into("<I", image, text_header + 8, new_virtual_size)
|
||||
|
||||
destination = destination.expanduser().absolute()
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = destination.with_name(f".{destination.name}.tmp")
|
||||
try:
|
||||
temporary.write_bytes(image)
|
||||
if _sha256(temporary) != PATCHED_SHA256:
|
||||
raise TextureReclaimPatchError(
|
||||
"built runtime patch does not match the verified V21 profile"
|
||||
)
|
||||
temporary.replace(destination)
|
||||
shutil.copystat(source, destination)
|
||||
except BaseException:
|
||||
temporary.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
return TextureReclaimPatchResult(
|
||||
source=str(source),
|
||||
destination=str(destination),
|
||||
source_sha256=SOURCE_SHA256,
|
||||
destination_sha256=PATCHED_SHA256,
|
||||
entry_va=f"0x{ENTRY_VA:08x}",
|
||||
cave_va=f"0x{CAVE_VA:08x}",
|
||||
hook_size=len(hook),
|
||||
old_text_virtual_size=old_virtual_size,
|
||||
new_text_virtual_size=new_virtual_size,
|
||||
commands=tuple(commands),
|
||||
created_at=datetime.now().astimezone().isoformat(),
|
||||
)
|
||||
|
||||
|
||||
def _find_pe_section(image: bytes | bytearray, section_name: bytes) -> int:
|
||||
if len(image) < 0x40:
|
||||
raise TextureReclaimPatchError("source is too small to be a PE image")
|
||||
pe_offset = struct.unpack_from("<I", image, 0x3C)[0]
|
||||
if image[pe_offset : pe_offset + 4] != b"PE\0\0":
|
||||
raise TextureReclaimPatchError("source does not have a valid PE signature")
|
||||
number_of_sections = struct.unpack_from("<H", image, pe_offset + 6)[0]
|
||||
optional_header_size = struct.unpack_from("<H", image, pe_offset + 20)[0]
|
||||
section_table = pe_offset + 24 + optional_header_size
|
||||
for index in range(number_of_sections):
|
||||
header = section_table + index * 40
|
||||
name = bytes(image[header : header + 8]).rstrip(b"\0")
|
||||
if name == section_name:
|
||||
return header
|
||||
raise TextureReclaimPatchError(f"PE section is missing: {section_name!r}")
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "ptdiag"
|
||||
version = "0.5.2"
|
||||
name = "PTEU-Linux-Launcher"
|
||||
version = "0.8.1"
|
||||
description = "PristonTale EU Linux installer, clean launcher, and diagnostic harness"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = []
|
||||
|
||||
11
updates/stable.json
Normal file
11
updates/stable.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"archive_sha256": "bdf72fdb9db79b2372b16feef5935fff7fd654f81e89b376c59f71dac81cc64f",
|
||||
"archive_size": 255328,
|
||||
"archive_url": "https://git.m4ko.net/m4ko/pristontale-eu-linux/releases/download/v0.8.1/pristontale-eu-linux-update-v0.8.1.tar.gz",
|
||||
"channel": "stable",
|
||||
"minimum_updater_version": "0.5.0",
|
||||
"notes_url": "https://git.m4ko.net/m4ko/pristontale-eu-linux/releases/tag/v0.8.1",
|
||||
"published_at": "2026-09-01T18:09:16Z",
|
||||
"schema": 1,
|
||||
"version": "0.8.1"
|
||||
}
|
||||
BIN
updates/stable.json.sig
Normal file
BIN
updates/stable.json.sig
Normal file
Binary file not shown.
Reference in New Issue
Block a user