Cleanup and security updates.

This commit is contained in:
2026-08-30 11:24:02 +02:00
parent ae21f6c64a
commit 61700df17e
21 changed files with 374 additions and 1504 deletions

View File

@@ -1,17 +1,25 @@
# PristonTale EU for Linux # PristonTale EU for Linux
Version 0.5.2 installs, updates, repairs, and launches PristonTale EU through a Version 0.6.0 installs, updates, repairs, and launches PristonTale EU through a
native Linux dashboard. Game files come directly from the publisher and are not native Linux dashboard. Game files come directly from the publisher and are not
included in this archive. included in this archive.
## Install ## Install
Keep the archive and checksum file together, then run: Keep the archive, detached signature, checksum, and
`launcher-update-public.pem` together. First confirm the public-key fingerprint
through an independent trusted channel; the expected DER SHA-256 fingerprint is
`3ae6e8dc483b93796b32e3ca270c367d247caf8d047eb796826c8e558a4a9e34`.
Then run:
```text ```text
sha256sum -c pristontale-eu-linux-installer-v0-5-2.tar.gz.sha256 openssl pkey -pubin -in launcher-update-public.pem -outform DER | sha256sum
tar -xzf pristontale-eu-linux-installer-v0-5-2.tar.gz openssl dgst -sha256 -verify launcher-update-public.pem \
cd pristontale-eu-linux-installer-v0-5-2 -signature pristontale-eu-linux-installer-v0-6-0.tar.gz.sig \
pristontale-eu-linux-installer-v0-6-0.tar.gz
sha256sum -c pristontale-eu-linux-installer-v0-6-0.tar.gz.sha256
tar -xzf pristontale-eu-linux-installer-v0-6-0.tar.gz
cd pristontale-eu-linux-installer-v0-6-0
./install.sh ./install.sh
``` ```
@@ -37,11 +45,6 @@ pristontale-eu gui
The main launcher button automatically shows **Install**, **Update**, or The main launcher button automatically shows **Install**, **Update**, or
**Play** according to the managed game's state. **Play** according to the managed game's state.
Version 0.5.0 is the self-update bootstrap. Future signed launcher releases are
checked in the background and appear as an **Update Launcher** action. Launcher
updates are staged separately from game files and retain the previous version
for rollback.
## Commands ## Commands
```text ```text
@@ -49,16 +52,22 @@ pristontale-eu --help Show all commands and options
pristontale-eu gui Open the native dashboard pristontale-eu gui Open the native dashboard
pristontale-eu doctor Check required host components pristontale-eu doctor Check required host components
pristontale-eu login-diagnostics Check the login route without credentials pristontale-eu login-diagnostics Check the login route without credentials
pristontale-eu login-diagnostics --runtime proton7
Compare the direct-Proton prefix read-only
pristontale-eu login-trace Retired; refuses without starting the game
pristontale-eu update Install or update official files pristontale-eu update Install or update official files
pristontale-eu update --repair Verify and repair every game file pristontale-eu update --repair Verify and repair every game file
pristontale-eu update --dry-run Preview an update pristontale-eu update --dry-run Preview an update
pristontale-eu import-game "/path/to/game" Import an existing installation pristontale-eu import-game "/path/to/game" Import an existing installation
pristontale-eu prepare Build the safe runtime view pristontale-eu prepare Build the safe runtime view
pristontale-eu play Launch through Gamescope/Proton pristontale-eu play Launch through the managed Proton window
pristontale-eu play --no-box Launch without Gamescope pristontale-eu play --no-box Launch without X11 window styling
pristontale-eu play --no-world-fix Compatibility alias for the pristine launch
pristontale-eu settings Show current settings pristontale-eu settings Show current settings
pristontale-eu settings --init Write default settings pristontale-eu settings --init Create/migrate settings without resetting choices
pristontale-eu settings --reset Explicitly restore every launcher default
pristontale-eu launcher-update --check Check the signed launcher channel pristontale-eu launcher-update --check Check the signed launcher channel
pristontale-eu launcher-update --verify Validate the full release without installing it
pristontale-eu launcher-update Install and activate an update pristontale-eu launcher-update Install and activate an update
pristontale-eu launcher-update --rollback Restore the previous launcher pristontale-eu launcher-update --rollback Restore the previous launcher
pristontale-eu sync Sync without import discovery pristontale-eu sync Sync without import discovery
@@ -66,7 +75,6 @@ pristontale-eu wine-update Run the legacy WPF updater
pristontale-eu fetch Fetch the publisher launcher pristontale-eu fetch Fetch the publisher launcher
pristontale-eu setup Prepare its Wine environment pristontale-eu setup Prepare its Wine environment
pristontale-eu cache-compatibility PATH Cache a verified compatibility pair pristontale-eu cache-compatibility PATH Cache a verified compatibility pair
pristontale-eu cache-runtime-patch PATH Cache a verified runtime patch
``` ```
Use `./install.sh --dry-run` to preview dependency installation or Use `./install.sh --dry-run` to preview dependency installation or

View File

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

Binary file not shown.

Binary file not shown.

View File

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

11
dist/stable.json vendored
View File

@@ -1,11 +0,0 @@
{
"archive_sha256": "e391accd5448f3239aeda2561e5faf2eb17b5aa5c61837da609f456815398002",
"archive_size": 71737,
"archive_url": "https://git.m4ko.net/m4ko/pristontale-eu-linux/releases/download/v0.5.2/pristontale-eu-linux-update-v0.5.2.tar.gz",
"channel": "stable",
"minimum_updater_version": "0.5.0",
"notes_url": "https://git.m4ko.net/m4ko/pristontale-eu-linux/releases/tag/v0.5.2",
"published_at": "2026-08-27T19:01:33Z",
"schema": 1,
"version": "0.5.2"
}

BIN
dist/stable.json.sig vendored

Binary file not shown.

View File

@@ -202,7 +202,15 @@ install -d -m 0755 \
"${STAGING_RELEASE}/ptlinux" \ "${STAGING_RELEASE}/ptlinux" \
"${STAGING_RELEASE}/ptlinux/native" \ "${STAGING_RELEASE}/ptlinux/native" \
"${STAGING_RELEASE}/ptdiag" "${STAGING_RELEASE}/ptdiag"
install -m 0644 "${SOURCE_ROOT}/ptlinux/"*.py "${STAGING_RELEASE}/ptlinux/" for SOURCE_MODULE in "${SOURCE_ROOT}/ptlinux/"*.py
do
case "${SOURCE_MODULE##*/}" in
desktop_entry.py|launcher_wrapper.py)
continue
;;
esac
install -m 0644 "$SOURCE_MODULE" "${STAGING_RELEASE}/ptlinux/"
done
install -m 0644 \ install -m 0644 \
"${SOURCE_ROOT}/ptlinux/launcher-update-public.pem" \ "${SOURCE_ROOT}/ptlinux/launcher-update-public.pem" \
"${STAGING_RELEASE}/ptlinux/launcher-update-public.pem" "${STAGING_RELEASE}/ptlinux/launcher-update-public.pem"
@@ -292,7 +300,7 @@ CURRENT_LINK="${APP_ROOT}/.current.$$.tmp"
ln -s -- "releases/${VERSION}" "$CURRENT_LINK" ln -s -- "releases/${VERSION}" "$CURRENT_LINK"
mv -Tf -- "$CURRENT_LINK" "${APP_ROOT}/current" mv -Tf -- "$CURRENT_LINK" "${APP_ROOT}/current"
"$PYTHON_BIN" "${RELEASE_ROOT}/ptlinux/launcher_wrapper.py" \ "$PYTHON_BIN" "${SOURCE_ROOT}/ptlinux/launcher_wrapper.py" \
"${SOURCE_ROOT}/packaging/pristontale-eu" \ "${SOURCE_ROOT}/packaging/pristontale-eu" \
"$APP_ROOT" \ "$APP_ROOT" \
"$INSTALL_DIRECTORY" \ "$INSTALL_DIRECTORY" \
@@ -300,7 +308,7 @@ mv -Tf -- "$CURRENT_LINK" "${APP_ROOT}/current"
install -m 0644 \ install -m 0644 \
"${SOURCE_ROOT}/packaging/eu.pristontale.PTLinux.png" \ "${SOURCE_ROOT}/packaging/eu.pristontale.PTLinux.png" \
"$ICON_PATH" "$ICON_PATH"
"$PYTHON_BIN" "${RELEASE_ROOT}/ptlinux/desktop_entry.py" \ "$PYTHON_BIN" "${SOURCE_ROOT}/ptlinux/desktop_entry.py" \
"${SOURCE_ROOT}/packaging/eu.pristontale.PTLinux.desktop" \ "${SOURCE_ROOT}/packaging/eu.pristontale.PTLinux.desktop" \
"${BIN_HOME}/pristontale-eu" \ "${BIN_HOME}/pristontale-eu" \
"$ICON_PATH" \ "$ICON_PATH" \

File diff suppressed because it is too large Load Diff

View File

@@ -25,6 +25,7 @@ from .depot import SyncProgress, sync_managed_game
from .doctor import inspect_host from .doctor import inspect_host
from .download import OFFICIAL_LAUNCHER_URL, fetch_official_launcher from .download import OFFICIAL_LAUNCHER_URL, fetch_official_launcher
from .importer import GameImportResult, discover_existing_game, import_existing_game from .importer import GameImportResult, discover_existing_game, import_existing_game
from .import_state import game_import_requires_verification, mark_game_import_verified
from .launcher import ( from .launcher import (
LauncherError, LauncherError,
configure_launcher_prefix, configure_launcher_prefix,
@@ -292,7 +293,6 @@ def main(argv: Sequence[str] | None = None) -> int:
paths, paths,
arguments.game_dir, arguments.game_dir,
arguments.no_box, arguments.no_box,
arguments.publisher_dll,
arguments.dry_run, arguments.dry_run,
arguments.skip_runtime_setup, arguments.skip_runtime_setup,
arguments.json, arguments.json,
@@ -422,10 +422,6 @@ def _login_diagnostics(
f"Depot: build {report.depot_build} " f"Depot: build {report.depot_build} "
f"({report.depot_version or 'unknown version'})" f"({report.depot_version or 'unknown version'})"
) )
world_fix_status = "enabled" if report.world_fix_enabled else "retired"
print(f"World fix: {world_fix_status}")
if report.world_fix_disabled_reason:
print(f"Reason: {report.world_fix_disabled_reason}")
if report.runtime_kind: if report.runtime_kind:
print(f"Runtime: {report.runtime_kind} ({report.runtime_path or 'unknown path'})") print(f"Runtime: {report.runtime_kind} ({report.runtime_path or 'unknown path'})")
else: else:
@@ -532,13 +528,22 @@ def _native_update(
runner, runner,
announce=not as_json, announce=not as_json,
) )
verify_import = game_import is not None or game_import_requires_verification(paths)
_print_operation_status(as_json, 2, 3, "Synchronizing official game files…") _print_operation_status(as_json, 2, 3, "Synchronizing official game files…")
result = sync_managed_game( result = sync_managed_game(
paths, paths,
repair=repair, repair=repair or verify_import,
quarantine_unmanaged=verify_import,
dry_run=dry_run, dry_run=dry_run,
progress=None if as_json else _print_sync_progress, progress=None if as_json else _print_sync_progress,
) )
if verify_import and not dry_run:
mark_game_import_verified(
paths,
build=result.build,
files_quarantined=result.files_quarantined,
quarantine_directory=result.quarantine_directory,
)
overlay = None overlay = None
if not dry_run and paths.source_game.is_dir(): if not dry_run and paths.source_game.is_dir():
_print_operation_status(as_json, 3, 3, "Preparing the clean game runtime view…") _print_operation_status(as_json, 3, 3, "Preparing the clean game runtime view…")
@@ -587,6 +592,23 @@ def _wine_update(
announce=not as_json, announce=not as_json,
) )
updater = launch_official_updater(paths, tools, runner) updater = launch_official_updater(paths, tools, runner)
native_verification = None
verify_import = not dry_run and (
game_import is not None or game_import_requires_verification(paths)
)
if verify_import:
native_verification = sync_managed_game(
paths,
repair=True,
quarantine_unmanaged=True,
progress=None if as_json else _print_sync_progress,
)
mark_game_import_verified(
paths,
build=native_verification.build,
files_quarantined=native_verification.files_quarantined,
quarantine_directory=native_verification.quarantine_directory,
)
overlay = None overlay = None
if not dry_run and paths.source_game.is_dir(): if not dry_run and paths.source_game.is_dir():
overlay = asdict(prepare_runtime_view(paths, paths.source_game)) overlay = asdict(prepare_runtime_view(paths, paths.source_game))
@@ -595,6 +617,9 @@ def _wine_update(
"dry_run": dry_run, "dry_run": dry_run,
"game_import": asdict(game_import) if game_import else None, "game_import": asdict(game_import) if game_import else None,
"updater": asdict(updater), "updater": asdict(updater),
"native_verification": (
asdict(native_verification) if native_verification is not None else None
),
"runtime_view": overlay, "runtime_view": overlay,
} }
_emit(value, as_json) _emit(value, as_json)
@@ -662,6 +687,11 @@ def _maybe_import_game(
def _prepare(paths: AppPaths, game_dir: Path | None, as_json: bool) -> int: def _prepare(paths: AppPaths, game_dir: Path | None, as_json: bool) -> int:
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) settings = ensure_settings(paths)
source = _source_game(paths, settings, game_dir) source = _source_game(paths, settings, game_dir)
result = prepare_runtime_view(paths, source) result = prepare_runtime_view(paths, source)
@@ -673,7 +703,6 @@ def _play(
paths: AppPaths, paths: AppPaths,
game_dir: Path | None, game_dir: Path | None,
no_box: bool, no_box: bool,
publisher_dll: bool,
dry_run: bool, dry_run: bool,
skip_runtime_setup: bool, skip_runtime_setup: bool,
as_json: bool, as_json: bool,
@@ -683,7 +712,6 @@ def _play(
paths, paths,
game_dir, game_dir,
no_box, no_box,
publisher_dll,
dry_run, dry_run,
skip_runtime_setup, skip_runtime_setup,
as_json, as_json,
@@ -694,14 +722,15 @@ def _play_locked(
paths: AppPaths, paths: AppPaths,
game_dir: Path | None, game_dir: Path | None,
no_box: bool, no_box: bool,
publisher_dll: bool,
dry_run: bool, dry_run: bool,
skip_runtime_setup: bool, skip_runtime_setup: bool,
as_json: bool, as_json: bool,
) -> int: ) -> int:
# The legacy flag is retained for command-line compatibility. V21 performs if game_dir is None and game_import_requires_verification(paths):
# periodic module-code checks, so every production launch is publisher-pristine. raise LauncherError(
_ = publisher_dll "the imported game has not completed its full publisher verification; "
"run pristontale-eu update before playing"
)
settings = _load_or_default_settings(paths, persist=not dry_run) settings = _load_or_default_settings(paths, persist=not dry_run)
source = _source_game(paths, settings, game_dir) source = _source_game(paths, settings, game_dir)
overlay = None overlay = None
@@ -729,7 +758,6 @@ def _play_locked(
selection, selection,
runner, runner,
use_gamebox=not no_box, use_gamebox=not no_box,
use_world_fix=False,
) )
except KeyboardInterrupt: except KeyboardInterrupt:
current_game_log = _current_game_log( current_game_log = _current_game_log(
@@ -751,7 +779,6 @@ def _play_locked(
and runtime_issues["host_futex_fatal_count"] and runtime_issues["host_futex_fatal_count"]
): ):
runtime_issues["recommended_one_variable_comparison"] = "proton7" runtime_issues["recommended_one_variable_comparison"] = "proton7"
world_fix_status = None
_emit( _emit(
{ {
"ok": outcome.returncode == 0, "ok": outcome.returncode == 0,
@@ -762,7 +789,6 @@ def _play_locked(
"game": asdict(outcome), "game": asdict(outcome),
"current_game_log": current_game_log, "current_game_log": current_game_log,
"runtime_issues": runtime_issues, "runtime_issues": runtime_issues,
"world_fix": world_fix_status,
}, },
as_json, as_json,
) )

View File

@@ -13,6 +13,7 @@ import json
import os import os
from pathlib import Path, PurePosixPath from pathlib import Path, PurePosixPath
import re import re
import stat
import threading import threading
import time import time
from typing import Callable, Iterable, Mapping, Protocol from typing import Callable, Iterable, Mapping, Protocol
@@ -38,6 +39,35 @@ DOWNLOAD_RETRY_DELAYS_SECONDS = (1.0, 2.0, 4.0, 8.0, 16.0)
PROGRESS_UPDATE_INTERVAL_SECONDS = 0.075 PROGRESS_UPDATE_INTERVAL_SECONDS = 0.075
MAX_CREATE_IF_MISSING_BYTES = 16 * 1024 * 1024 MAX_CREATE_IF_MISSING_BYTES = 16 * 1024 * 1024
MAX_LINE_ENDING_RECOVERY_BYTES = 1024 * 1024 MAX_LINE_ENDING_RECOVERY_BYTES = 1024 * 1024
UNMANAGED_EXECUTABLE_SUFFIXES = frozenset(
{
".asi",
".bat",
".cmd",
".com",
".cpl",
".desktop",
".dll",
".drv",
".exe",
".jar",
".js",
".lnk",
".lua",
".msi",
".ocx",
".ps1",
".py",
".reg",
".scr",
".sh",
".so",
".url",
".vbe",
".vbs",
".wsf",
}
)
ALWAYS_VERIFY_PATHS = frozenset( ALWAYS_VERIFY_PATHS = frozenset(
{"game.exe", "game.dll", "gamese.exe", "gamese.dll"} {"game.exe", "game.dll", "gamese.exe", "gamese.dll"}
) )
@@ -132,11 +162,21 @@ class DepotSyncResult:
bytes_downloaded: int bytes_downloaded: int
files_deleted: int files_deleted: int
files_created: int files_created: int
files_quarantined: int
quarantine_directory: str | None
repair: bool repair: bool
dry_run: bool dry_run: bool
completed_at: str completed_at: str
@dataclass(frozen=True)
class QuarantineResult:
"""Recoverable unmanaged entries removed before authenticating an import."""
files: int
directory: str | None
ProgressCallback = Callable[[SyncProgress], None] ProgressCallback = Callable[[SyncProgress], None]
@@ -486,6 +526,7 @@ def sync_managed_game(
paths: AppPaths, paths: AppPaths,
*, *,
repair: bool = False, repair: bool = False,
quarantine_unmanaged: bool = False,
dry_run: bool = False, dry_run: bool = False,
progress: ProgressCallback | None = None, progress: ProgressCallback | None = None,
client: DepotClient | None = None, client: DepotClient | None = None,
@@ -496,6 +537,9 @@ def sync_managed_game(
depot_client = client or DepotClient() depot_client = client or DepotClient()
_emit_progress(progress, "manifest", 0, 0, 0, 0, None) _emit_progress(progress, "manifest", 0, 0, 0, 0, None)
snapshot = depot_client.fetch_snapshot() snapshot = depot_client.fetch_snapshot()
quarantine = QuarantineResult(0, None)
if quarantine_unmanaged and not dry_run:
quarantine = _quarantine_unmanaged_files(paths, snapshot)
plan = plan_update(paths.source_game, snapshot, repair=repair) plan = plan_update(paths.source_game, snapshot, repair=repair)
_emit_progress( _emit_progress(
progress, progress,
@@ -508,7 +552,16 @@ def sync_managed_game(
) )
if dry_run: if dry_run:
return _sync_result(snapshot, plan, 0, 0, 0, repair, True) return _sync_result(
snapshot,
plan,
0,
0,
0,
repair,
True,
quarantine=quarantine,
)
root = _prepare_game_root(paths.source_game, create=True) root = _prepare_game_root(paths.source_game, create=True)
_download_plan(depot_client, snapshot, root, plan, progress) _download_plan(depot_client, snapshot, root, plan, progress)
@@ -523,6 +576,7 @@ def sync_managed_game(
repair, repair,
False, False,
created=created, created=created,
quarantine=quarantine,
) )
_write_json(paths.native_depot_state, asdict(result)) _write_json(paths.native_depot_state, asdict(result))
_emit_progress( _emit_progress(
@@ -537,6 +591,110 @@ def sync_managed_game(
return result return result
def _quarantine_unmanaged_files(
paths: AppPaths,
snapshot: DepotSnapshot,
) -> QuarantineResult:
"""Move non-depot import contents aside without following links."""
root = _prepare_game_root(paths.source_game, create=False)
if not root.exists():
return QuarantineResult(0, None)
allowed = {
file.path for file in snapshot.files
} | {
entry.path for entry in snapshot.create_if_missing
}
candidates: list[tuple[Path, str]] = []
def walk_error(error: OSError) -> None:
raise DepotError(f"cannot inspect imported game tree: {error}") from error
for directory, directory_names, file_names in os.walk(
root,
topdown=True,
followlinks=False,
onerror=walk_error,
):
parent = Path(directory)
for name in tuple(directory_names):
path = parent / name
try:
status = path.lstat()
except OSError as exc:
raise DepotError(f"cannot inspect imported entry {path}: {exc}") from exc
if stat.S_ISDIR(status.st_mode):
continue
directory_names.remove(name)
candidates.append((path, path.relative_to(root).as_posix()))
for name in file_names:
path = parent / name
relative = path.relative_to(root).as_posix()
try:
status = path.lstat()
except OSError as exc:
raise DepotError(f"cannot inspect imported entry {path}: {exc}") from exc
if not stat.S_ISREG(status.st_mode):
candidates.append((path, relative))
continue
if relative in allowed:
continue
if _safe_imported_user_file(path, relative):
continue
candidates.append((path, relative))
if not candidates:
return QuarantineResult(0, None)
base = paths.game_import_quarantine.resolve(strict=False)
data_root = paths.data.resolve(strict=False)
try:
base.relative_to(data_root)
except ValueError as exc:
raise DepotError(f"import quarantine is outside the data root: {base}") from exc
stamp = datetime.now().astimezone().strftime("%Y%m%d-%H%M%S")
quarantine = base / f"{stamp}-{uuid4().hex}"
files_root = quarantine / "files"
files_root.mkdir(parents=True, exist_ok=False)
manifest = {
"schema": 1,
"complete": False,
"created_at": datetime.now().astimezone().isoformat(),
"source": str(root),
"files": [relative for _path, relative in candidates],
}
_write_json(quarantine / "quarantine.json", manifest)
for source, relative in candidates:
destination = files_root.joinpath(*PurePosixPath(relative).parts)
destination.parent.mkdir(parents=True, exist_ok=True)
try:
source.replace(destination)
except OSError as exc:
raise DepotError(
f"could not quarantine imported entry {relative}: {exc}"
) from exc
manifest["complete"] = True
_write_json(quarantine / "quarantine.json", manifest)
return QuarantineResult(len(candidates), str(quarantine))
def _safe_imported_user_file(path: Path, relative: str) -> bool:
parts = PurePosixPath(relative).parts
if not parts or parts[0].casefold() != "save":
return False
if path.suffix.casefold() in UNMANAGED_EXECUTABLE_SUFFIXES:
return False
try:
with path.open("rb") as handle:
prefix = handle.read(4)
except OSError as exc:
raise DepotError(f"cannot inspect imported user file {path}: {exc}") from exc
return not (
prefix.startswith(b"MZ")
or prefix.startswith(b"\x7fELF")
or prefix.startswith(b"#!")
)
def _download_plan( def _download_plan(
client: DepotClient, client: DepotClient,
depot_snapshot: DepotSnapshot, depot_snapshot: DepotSnapshot,
@@ -699,6 +857,7 @@ def _sync_result(
dry_run: bool, dry_run: bool,
*, *,
created: int = 0, created: int = 0,
quarantine: QuarantineResult = QuarantineResult(0, None),
) -> DepotSyncResult: ) -> DepotSyncResult:
return DepotSyncResult( return DepotSyncResult(
region=snapshot.region, region=snapshot.region,
@@ -711,6 +870,8 @@ def _sync_result(
bytes_downloaded=bytes_downloaded, bytes_downloaded=bytes_downloaded,
files_deleted=files_deleted, files_deleted=files_deleted,
files_created=created, files_created=created,
files_quarantined=quarantine.files,
quarantine_directory=quarantine.directory,
repair=repair, repair=repair,
dry_run=dry_run, dry_run=dry_run,
completed_at=datetime.now().astimezone().isoformat(), completed_at=datetime.now().astimezone().isoformat(),

View File

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

View File

@@ -7,6 +7,7 @@ from enum import Enum
import json import json
from pathlib import Path from pathlib import Path
from .import_state import game_import_requires_verification
from .paths import AppPaths from .paths import AppPaths
@@ -70,6 +71,8 @@ def inspect_game_installation(
local_build = _read_verified_local_build(paths.native_depot_state) local_build = _read_verified_local_build(paths.native_depot_state)
if not installed: if not installed:
action = PrimaryAction.INSTALL action = PrimaryAction.INSTALL
elif game_import_requires_verification(paths):
action = PrimaryAction.UPDATE
elif official_build is not None and ( elif official_build is not None and (
local_build is None or local_build < official_build local_build is None or local_build < official_build
): ):

View File

@@ -36,12 +36,6 @@ from .runtime import (
select_runtime, select_runtime,
) )
from .settings import Settings from .settings import Settings
from .world_fix import (
WORLD_FIX_DISABLED_REASON,
WORLD_FIX_ENABLED,
WORLD_FIX_HELPER_SHA256,
packaged_world_fix_helper,
)
LOGIN_SERVER_HOST = "5.135.35.188" LOGIN_SERVER_HOST = "5.135.35.188"
@@ -149,11 +143,6 @@ class LoginDiagnosticReport:
depot_build: int | None depot_build: int | None
depot_version: str | None depot_version: str | None
depot_completed_at: str | None depot_completed_at: str | None
world_fix_helper: str
world_fix_helper_sha256: str | None
world_fix_helper_ready: bool
world_fix_enabled: bool
world_fix_disabled_reason: str | None
runtime_configured: str runtime_configured: str
runtime_requested: str runtime_requested: str
runtime_override_applied: bool runtime_override_applied: bool
@@ -237,7 +226,7 @@ def inspect_login_environment(
and selection.kind in {"proton7", "proton7-container"} and selection.kind in {"proton7", "proton7-container"}
else None else None
) )
game_root = _active_game_root(paths, settings) game_root = _active_game_root(paths)
game_exe = game_root / "Game.exe" game_exe = game_root / "Game.exe"
game_dll = game_root / "game.dll" game_dll = game_root / "game.dll"
game_exe_sha256 = _sha256_if_file(game_exe) game_exe_sha256 = _sha256_if_file(game_exe)
@@ -252,12 +241,6 @@ def inspect_login_environment(
) )
game_profile = classify_game_profile(game_exe_sha256, game_dll_sha256) game_profile = classify_game_profile(game_exe_sha256, game_dll_sha256)
depot_state = _read_json_object(paths.native_depot_state) depot_state = _read_json_object(paths.native_depot_state)
world_fix_helper = packaged_world_fix_helper()
world_fix_helper_sha256 = _sha256_if_file(world_fix_helper)
world_fix_helper_ready = (
WORLD_FIX_ENABLED
and world_fix_helper_sha256 == WORLD_FIX_HELPER_SHA256
)
prefix = _runtime_prefix(paths, selection) prefix = _runtime_prefix(paths, selection)
prefix_ready = _prefix_ready(prefix) prefix_ready = _prefix_ready(prefix)
directx_ready = native_d3d_ready(prefix) if prefix is not None else False directx_ready = native_d3d_ready(prefix) if prefix is not None else False
@@ -271,7 +254,7 @@ def inspect_login_environment(
if selection is not None and prefix_ready and runner is not None if selection is not None and prefix_ready and runner is not None
else None else None
) )
latest_log = latest_native_game_log(paths, settings) latest_log = latest_native_game_log(paths)
warnings = _diagnostic_warnings( warnings = _diagnostic_warnings(
probe, probe,
game_profile, game_profile,
@@ -333,13 +316,6 @@ def inspect_login_environment(
depot_build=_integer_or_none(depot_state.get("build")), depot_build=_integer_or_none(depot_state.get("build")),
depot_version=_string_or_none(depot_state.get("version")), depot_version=_string_or_none(depot_state.get("version")),
depot_completed_at=_string_or_none(depot_state.get("completed_at")), depot_completed_at=_string_or_none(depot_state.get("completed_at")),
world_fix_helper=str(world_fix_helper),
world_fix_helper_sha256=world_fix_helper_sha256,
world_fix_helper_ready=world_fix_helper_ready,
world_fix_enabled=WORLD_FIX_ENABLED,
world_fix_disabled_reason=(
None if WORLD_FIX_ENABLED else WORLD_FIX_DISABLED_REASON
),
runtime_configured=settings.runtime, runtime_configured=settings.runtime,
runtime_requested=runtime_requested, runtime_requested=runtime_requested,
runtime_override_applied=runtime_override is not None, runtime_override_applied=runtime_override is not None,
@@ -473,7 +449,7 @@ def classify_game_profile(
return "unknown" return "unknown"
def latest_native_game_log(paths: AppPaths, settings: Settings) -> Path | None: def latest_native_game_log(paths: AppPaths) -> Path | None:
"""Return the newest native log from the active managed source or runtime view.""" """Return the newest native log from the active managed source or runtime view."""
directories = (paths.runtime_view / "save/logs", paths.source_game / "save/logs") directories = (paths.runtime_view / "save/logs", paths.source_game / "save/logs")
@@ -494,7 +470,7 @@ def latest_native_game_log(paths: AppPaths, settings: Settings) -> Path | None:
return None return None
def _active_game_root(paths: AppPaths, settings: Settings) -> Path: def _active_game_root(paths: AppPaths) -> Path:
if paths.runtime_view.is_dir(): if paths.runtime_view.is_dir():
return paths.runtime_view return paths.runtime_view
return paths.source_game return paths.source_game

Binary file not shown.

View File

@@ -19,7 +19,6 @@ from ptdiag.assbin import (
) )
from .paths import AppPaths from .paths import AppPaths
from .world_fix import V21_BINARY_HASHES
WHISPERING_VALE_RELATIVE = Path("game/maps/WhisperingVale.rovug") WHISPERING_VALE_RELATIVE = Path("game/maps/WhisperingVale.rovug")
@@ -35,7 +34,10 @@ COMPATIBILITY_BINARY_HASHES = {
"Game.exe": "3cc0980aac0f65b379ff340ec61750498fb9f620fb6c88aa66281e8ab27023a5", "Game.exe": "3cc0980aac0f65b379ff340ec61750498fb9f620fb6c88aa66281e8ab27023a5",
"game.dll": "7ea85b19f452962a39c91f967cad22495f066b2d52e8ac37f4e234463d5c4f09", "game.dll": "7ea85b19f452962a39c91f967cad22495f066b2d52e8ac37f4e234463d5c4f09",
} }
CURRENT_V21_BINARY_HASHES = V21_BINARY_HASHES CURRENT_V21_BINARY_HASHES = {
"Game.exe": "2bc3edd26c5f6d7ae9bbdc23967c5aefd374bdf934fcdfa2ed42c6442e550837",
"game.dll": "4b4e5c28e2bfdaa8d36c8341fad27becfd03faf63aa8ea874f5d43c5b1961868",
}
PATCHED_V21_GAME_DLL_SHA256 = ( PATCHED_V21_GAME_DLL_SHA256 = (
"0be11b06ccf9be747e32b195c8d5980062f446d7d3a0a662ebfe0e602a01c538" "0be11b06ccf9be747e32b195c8d5980062f446d7d3a0a662ebfe0e602a01c538"
) )
@@ -87,8 +89,6 @@ class RuntimeCompatibility:
def inspect_runtime_compatibility( def inspect_runtime_compatibility(
paths: AppPaths, paths: AppPaths,
source_game: Path, source_game: Path,
*,
use_world_fix: bool = True,
) -> RuntimeCompatibility: ) -> RuntimeCompatibility:
"""Check whether ``prepare_runtime_view`` can recognize the installed release. """Check whether ``prepare_runtime_view`` can recognize the installed release.
@@ -109,9 +109,6 @@ def inspect_runtime_compatibility(
inspection = inspect_wrapped_assbin(source_scene) inspection = inspect_wrapped_assbin(source_scene)
source_scene_sha256 = inspection.file_sha256 source_scene_sha256 = inspection.file_sha256
scene_action = _scene_action(inspection) scene_action = _scene_action(inspection)
# Retain the keyword for callers from the 0.5.8 candidate. The runtime
# view and loaded process are now always publisher-pristine.
_ = use_world_fix
binary_action = _inspect_runtime_binary_action(paths, source) binary_action = _inspect_runtime_binary_action(paths, source)
except (OSError, ValueError, OverlayError) as exc: except (OSError, ValueError, OverlayError) as exc:
reason = str(exc).strip() or type(exc).__name__ reason = str(exc).strip() or type(exc).__name__
@@ -136,8 +133,6 @@ def inspect_runtime_compatibility(
def prepare_runtime_view( def prepare_runtime_view(
paths: AppPaths, paths: AppPaths,
source_game: Path, source_game: Path,
*,
use_world_fix: bool = True,
) -> OverlayResult: ) -> OverlayResult:
"""Build a symlink view and keep the launcher-managed source read-only.""" """Build a symlink view and keep the launcher-managed source read-only."""
@@ -148,9 +143,6 @@ def prepare_runtime_view(
inspection = inspect_wrapped_assbin(source_scene) inspection = inspect_wrapped_assbin(source_scene)
action = _scene_action(inspection) action = _scene_action(inspection)
game_save = _prepare_persistent_save(paths, source) game_save = _prepare_persistent_save(paths, source)
# ``use_world_fix`` is retained as a compatibility keyword. The retired
# helper is never selected and no code path may replace the publisher DLL.
_ = use_world_fix
( (
binary_action, binary_action,
game_exe_source, game_exe_source,

View File

@@ -144,6 +144,12 @@ class AppPaths:
def game_import_manifest(self) -> Path: def game_import_manifest(self) -> Path:
return self.state / "game-import.json" return self.state / "game-import.json"
@property
def game_import_quarantine(self) -> Path:
"""Recoverable files excluded while authenticating an imported game."""
return self.data / "import-quarantine"
@property @property
def native_depot_state(self) -> Path: def native_depot_state(self) -> Path:
"""Last completed native depot synchronization.""" """Last completed native depot synchronization."""

View File

@@ -17,11 +17,6 @@ from .command import CommandOutcome, CommandRunner, OutputLineFilter
from .gamebox import build_gamebox_command from .gamebox import build_gamebox_command
from .paths import AppPaths from .paths import AppPaths
from .settings import Settings from .settings import Settings
from .world_fix import (
WorldFixError,
WorldFixStatusTarget,
resolve_world_fix_helper,
)
WINDOWS_GAME_EXE = r"C:\Program Files (x86)\Pristontale EU\Game.exe" WINDOWS_GAME_EXE = r"C:\Program Files (x86)\Pristontale EU\Game.exe"
@@ -289,8 +284,6 @@ def run_game(
runner: CommandRunner, runner: CommandRunner,
*, *,
use_gamebox: bool = True, use_gamebox: bool = True,
use_world_fix: bool = False,
world_fix_status_target: WorldFixStatusTarget | None = None,
timeout_seconds: float = 12 * 60 * 60, timeout_seconds: float = 12 * 60 * 60,
check: bool = True, check: bool = True,
extra_environment: Mapping[str, str] | None = None, extra_environment: Mapping[str, str] | None = None,
@@ -298,38 +291,21 @@ def run_game(
) -> CommandOutcome: ) -> CommandOutcome:
if not paths.runtime_view.is_dir() and not runner.dry_run: if not paths.runtime_view.is_dir() and not runner.dry_run:
raise RuntimeErrorPT("runtime view is missing; prepare the game first") raise RuntimeErrorPT("runtime view is missing; prepare the game first")
world_fix_helper = None
if use_world_fix and not runner.dry_run:
try:
world_fix_helper = resolve_world_fix_helper(paths.runtime_view)
except WorldFixError as exc:
raise RuntimeErrorPT(str(exc)) from exc
launch_executable = world_fix_helper or (paths.runtime_view / "Game.exe")
if selection.kind == "umu": if selection.kind == "umu":
game_command = [ game_command = [
selection.executable, selection.executable,
str(launch_executable), str(paths.runtime_view / "Game.exe"),
"PTEU_REBORN",
] ]
if world_fix_helper is None:
game_command.append("PTEU_REBORN")
elif world_fix_status_target is not None:
game_command.extend(
["--status-file", world_fix_status_target.windows_path]
)
environment = _umu_environment(paths) environment = _umu_environment(paths)
elif selection.kind == "proton7-container": elif selection.kind == "proton7-container":
if not selection.proton_path: if not selection.proton_path:
raise RuntimeErrorPT("Proton script path is missing") raise RuntimeErrorPT("Proton script path is missing")
game_command = [ game_command = [
selection.executable, selection.executable,
str(launch_executable), str(paths.runtime_view / "Game.exe"),
"PTEU_REBORN",
] ]
if world_fix_helper is None:
game_command.append("PTEU_REBORN")
elif world_fix_status_target is not None:
game_command.extend(
["--status-file", world_fix_status_target.windows_path]
)
environment = _umu_proton7_environment( environment = _umu_proton7_environment(
paths, paths,
selection, selection,
@@ -344,14 +320,9 @@ def run_game(
"ptlinux.proton_compat", "ptlinux.proton_compat",
selection.proton_path, selection.proton_path,
"runinprefix", "runinprefix",
str(world_fix_helper) if world_fix_helper is not None else WINDOWS_GAME_EXE, WINDOWS_GAME_EXE,
"PTEU_REBORN",
] ]
if world_fix_helper is None:
game_command.append("PTEU_REBORN")
elif world_fix_status_target is not None:
game_command.extend(
["--status-file", world_fix_status_target.windows_path]
)
environment = _proton_environment( environment = _proton_environment(
paths, paths,
selection, selection,

View File

@@ -6,6 +6,7 @@ import base64
from collections import deque from collections import deque
import json import json
import math import math
import os
import queue import queue
import subprocess import subprocess
import sys import sys
@@ -21,6 +22,7 @@ from .install_status import (
PrimaryAction, PrimaryAction,
inspect_game_installation, inspect_game_installation,
) )
from .import_state import game_import_requires_verification
from .news import ( from .news import (
Broadcast, Broadcast,
NewsArticle, NewsArticle,
@@ -63,6 +65,34 @@ class DashboardError(RuntimeError):
"""Raised when the native dashboard cannot be displayed.""" """Raised when the native dashboard cannot be displayed."""
def launcher_restart_spec(
paths: AppPaths,
environment: Mapping[str, str] | None = None,
) -> tuple[list[str], dict[str, str]]:
"""Build an isolated restart that imports the newly activated release."""
current = paths.launcher_current
if not current.is_symlink():
raise DashboardError(f"launcher current path is not a symbolic link: {current}")
try:
release = current.resolve(strict=True)
except OSError as exc:
raise DashboardError(f"launcher current release is unavailable: {exc}") from exc
releases = paths.launcher_releases.resolve(strict=False)
if release.parent != releases or not (release / "ptlinux").is_dir():
raise DashboardError(f"launcher current release is outside the release store: {release}")
env = dict(os.environ if environment is None else environment)
env.update(
{
"PTLINUX_INSTALL_DIR": str(paths.data),
"PTLINUX_APP_HOME": str(paths.app_home),
"PYTHONSAFEPATH": "1",
"PYTHONPATH": str(release),
}
)
return [sys.executable, "-B", "-P", "-m", "ptlinux", "gui"], env
class ProgressCoalescer: class ProgressCoalescer:
"""Keep only the latest cross-thread progress event for the Tk loop. """Keep only the latest cross-thread progress event for the Tk loop.
@@ -1160,7 +1190,7 @@ def run_dashboard(paths: AppPaths) -> None:
runtime_compatibility = None runtime_compatibility = None
local_status = inspect_game_installation(paths) local_status = inspect_game_installation(paths)
refresh_primary_action(update_activity=update_activity) refresh_primary_action(update_activity=update_activity)
if not local_status.installed: if not local_status.installed or game_import_requires_verification(paths):
return return
def worker() -> None: def worker() -> None:
@@ -1279,8 +1309,10 @@ def run_dashboard(paths: AppPaths) -> None:
if value == 0: if value == 0:
append_log("Launcher update activated; restarting the dashboard.") append_log("Launcher update activated; restarting the dashboard.")
try: try:
restart_command, restart_environment = launcher_restart_spec(paths)
subprocess.Popen( subprocess.Popen(
command_for("gui"), restart_command,
env=restart_environment,
close_fds=True, close_fds=True,
start_new_session=True, start_new_session=True,
) )

View File

@@ -1,191 +0,0 @@
"""Retired V21 world-helper metadata and legacy status-file parsing."""
from __future__ import annotations
from dataclasses import dataclass
import json
import os
from pathlib import Path, PureWindowsPath
V21_BINARY_HASHES = {
"Game.exe": "2bc3edd26c5f6d7ae9bbdc23967c5aefd374bdf934fcdfa2ed42c6442e550837",
"game.dll": "4b4e5c28e2bfdaa8d36c8341fad27becfd03faf63aa8ea874f5d43c5b1961868",
}
WORLD_FIX_HELPER_FILENAME = "pt-world-fix.exe"
# Filled from the reproducible stripped PE after the native helper is built.
WORLD_FIX_HELPER_SHA256 = (
"a7a17d29ef4c11f9603790f0110a4d6823b91cce03f70e466b7e0bdbd6fa313e"
)
WORLD_FIX_ENABLED = False
WORLD_FIX_DISABLED_REASON = (
"the experimental world fix is retired because PristonTale V21 periodically "
"checks the loaded game.dll code section; its five-byte map-loader detour "
"causes Module Code Sync Error / Banned Code 1"
)
WORLD_FIX_STATUS_PROTOCOL = 1
WORLD_FIX_STATUS_MAX_BYTES = 64 * 1024
@dataclass(frozen=True)
class WorldFixStatusTarget:
path: Path
windows_path: str
class WorldFixError(RuntimeError):
"""Raised when the retired helper is requested."""
def packaged_world_fix_helper() -> Path:
return Path(__file__).resolve().parent / "native" / WORLD_FIX_HELPER_FILENAME
def resolve_world_fix_helper(
game_directory: Path,
*,
helper: Path | None = None,
) -> Path | None:
"""Reject the retired helper before it can alter publisher module memory."""
_ = game_directory, helper
raise WorldFixError(WORLD_FIX_DISABLED_REASON)
def create_world_fix_status_target(
game_save: Path,
started_ns: int,
) -> WorldFixStatusTarget:
"""Allocate a unique launcher-owned status path without pre-creating it."""
if started_ns <= 0:
raise WorldFixError("world-fix status start time must be positive")
directory = game_save / "launcher"
if directory.is_symlink():
raise WorldFixError(
"the world-fix status directory must not be a symbolic link"
)
directory.mkdir(parents=True, exist_ok=True)
if not directory.is_dir():
raise WorldFixError("the world-fix status directory is unavailable")
filename = f"ptworld-{started_ns}-{os.getpid()}.jsonl"
path = directory / filename
if path.exists() or path.is_symlink():
raise WorldFixError(f"world-fix status target already exists: {path}")
windows_path = str(PureWindowsPath("save", "launcher", filename))
return WorldFixStatusTarget(path=path, windows_path=windows_path)
def read_world_fix_status(
target: WorldFixStatusTarget | None,
started_ns: int,
) -> dict[str, object] | None:
"""Read only the bounded, fresh status stream belonging to this game run."""
if target is None:
return None
report: dict[str, object] = {
"path": str(target.path),
"status": "missing",
"fresh": False,
"events": [],
}
try:
if target.path.is_symlink() or not target.path.is_file():
return report
state = target.path.stat()
except OSError as exc:
report["status"] = "unreadable"
report["error"] = str(exc)
return report
report["modified_at_ns"] = state.st_mtime_ns
report["size"] = state.st_size
if state.st_mtime_ns < started_ns:
report["status"] = "stale"
return report
report["fresh"] = True
if state.st_size > WORLD_FIX_STATUS_MAX_BYTES:
report["status"] = "oversized"
return report
try:
lines = target.path.read_text(encoding="utf-8").splitlines()
except OSError as exc:
report["status"] = "unreadable"
report["error"] = str(exc)
return report
events: list[dict[str, object]] = []
invalid_lines = 0
for line in lines[:64]:
try:
value = json.loads(line)
except (json.JSONDecodeError, TypeError):
invalid_lines += 1
continue
clean = _validated_status_event(value)
if clean is None:
invalid_lines += 1
continue
events.append(clean)
report["events"] = events
if invalid_lines:
report["invalid_lines"] = invalid_lines
event_names = {
event["event"]
for event in events
if isinstance(event.get("event"), str)
}
for name in (
"helper_started",
"game_started",
"game_dll_verified",
"hook_armed",
"login_accepted",
"world_fix_activated",
):
report[name] = name in event_names
if "world_fix_activated" in event_names:
report["status"] = "activated"
elif "world_fix_activation_failed" in event_names:
report["status"] = "activation_failed"
elif "world_fix_not_activated" in event_names:
report["status"] = "not_activated"
elif "hook_armed" in event_names:
report["status"] = "armed"
elif event_names & {
"game_path_failed",
"game_command_failed",
"game_start_failed",
"game_dll_unavailable",
"game_dll_rejected",
"hook_arm_failed",
}:
report["status"] = "helper_failed"
elif events:
report["status"] = "started"
else:
report["status"] = "invalid"
return report
def _validated_status_event(value: object) -> dict[str, object] | None:
if not isinstance(value, dict):
return None
if value.get("protocol") != WORLD_FIX_STATUS_PROTOCOL:
return None
event = value.get("event")
tick_ms = value.get("tick_ms")
if not isinstance(event, str) or not event or len(event) > 64:
return None
if isinstance(tick_ms, bool) or not isinstance(tick_ms, int) or tick_ms < 0:
return None
clean: dict[str, object] = {
"event": event,
"tick_ms": tick_ms,
}
for key in ("process_id", "base", "size", "windows_error", "exit_code"):
item = value.get(key)
if isinstance(item, bool) or not isinstance(item, int) or item < 0:
continue
clean[key] = item
return clean

View File

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