Cleanup and security updates.
This commit is contained in:
@@ -25,6 +25,7 @@ from .depot import SyncProgress, sync_managed_game
|
||||
from .doctor import inspect_host
|
||||
from .download import OFFICIAL_LAUNCHER_URL, fetch_official_launcher
|
||||
from .importer import GameImportResult, discover_existing_game, import_existing_game
|
||||
from .import_state import game_import_requires_verification, mark_game_import_verified
|
||||
from .launcher import (
|
||||
LauncherError,
|
||||
configure_launcher_prefix,
|
||||
@@ -292,7 +293,6 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
paths,
|
||||
arguments.game_dir,
|
||||
arguments.no_box,
|
||||
arguments.publisher_dll,
|
||||
arguments.dry_run,
|
||||
arguments.skip_runtime_setup,
|
||||
arguments.json,
|
||||
@@ -422,10 +422,6 @@ def _login_diagnostics(
|
||||
f"Depot: build {report.depot_build} "
|
||||
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:
|
||||
print(f"Runtime: {report.runtime_kind} ({report.runtime_path or 'unknown path'})")
|
||||
else:
|
||||
@@ -532,13 +528,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…")
|
||||
@@ -587,6 +592,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))
|
||||
@@ -595,6 +617,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)
|
||||
@@ -662,6 +687,11 @@ def _maybe_import_game(
|
||||
|
||||
|
||||
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)
|
||||
source = _source_game(paths, settings, game_dir)
|
||||
result = prepare_runtime_view(paths, source)
|
||||
@@ -673,7 +703,6 @@ def _play(
|
||||
paths: AppPaths,
|
||||
game_dir: Path | None,
|
||||
no_box: bool,
|
||||
publisher_dll: bool,
|
||||
dry_run: bool,
|
||||
skip_runtime_setup: bool,
|
||||
as_json: bool,
|
||||
@@ -683,7 +712,6 @@ def _play(
|
||||
paths,
|
||||
game_dir,
|
||||
no_box,
|
||||
publisher_dll,
|
||||
dry_run,
|
||||
skip_runtime_setup,
|
||||
as_json,
|
||||
@@ -694,14 +722,15 @@ def _play_locked(
|
||||
paths: AppPaths,
|
||||
game_dir: Path | None,
|
||||
no_box: bool,
|
||||
publisher_dll: bool,
|
||||
dry_run: bool,
|
||||
skip_runtime_setup: bool,
|
||||
as_json: bool,
|
||||
) -> int:
|
||||
# The legacy flag is retained for command-line compatibility. V21 performs
|
||||
# periodic module-code checks, so every production launch is publisher-pristine.
|
||||
_ = publisher_dll
|
||||
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)
|
||||
source = _source_game(paths, settings, game_dir)
|
||||
overlay = None
|
||||
@@ -729,7 +758,6 @@ def _play_locked(
|
||||
selection,
|
||||
runner,
|
||||
use_gamebox=not no_box,
|
||||
use_world_fix=False,
|
||||
)
|
||||
except KeyboardInterrupt:
|
||||
current_game_log = _current_game_log(
|
||||
@@ -751,7 +779,6 @@ def _play_locked(
|
||||
and runtime_issues["host_futex_fatal_count"]
|
||||
):
|
||||
runtime_issues["recommended_one_variable_comparison"] = "proton7"
|
||||
world_fix_status = None
|
||||
_emit(
|
||||
{
|
||||
"ok": outcome.returncode == 0,
|
||||
@@ -762,7 +789,6 @@ def _play_locked(
|
||||
"game": asdict(outcome),
|
||||
"current_game_log": current_game_log,
|
||||
"runtime_issues": runtime_issues,
|
||||
"world_fix": world_fix_status,
|
||||
},
|
||||
as_json,
|
||||
)
|
||||
|
||||
163
ptlinux/depot.py
163
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,6 +39,35 @@ 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"}
|
||||
)
|
||||
@@ -132,11 +162,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]
|
||||
|
||||
|
||||
@@ -486,6 +526,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,
|
||||
@@ -496,6 +537,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,
|
||||
@@ -508,7 +552,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)
|
||||
@@ -523,6 +576,7 @@ def sync_managed_game(
|
||||
repair,
|
||||
False,
|
||||
created=created,
|
||||
quarantine=quarantine,
|
||||
)
|
||||
_write_json(paths.native_depot_state, asdict(result))
|
||||
_emit_progress(
|
||||
@@ -537,6 +591,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,
|
||||
@@ -699,6 +857,7 @@ def _sync_result(
|
||||
dry_run: bool,
|
||||
*,
|
||||
created: int = 0,
|
||||
quarantine: QuarantineResult = QuarantineResult(0, None),
|
||||
) -> DepotSyncResult:
|
||||
return DepotSyncResult(
|
||||
region=snapshot.region,
|
||||
@@ -711,6 +870,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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -70,6 +71,8 @@ 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
|
||||
):
|
||||
|
||||
@@ -36,12 +36,6 @@ from .runtime import (
|
||||
select_runtime,
|
||||
)
|
||||
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"
|
||||
@@ -149,11 +143,6 @@ class LoginDiagnosticReport:
|
||||
depot_build: int | None
|
||||
depot_version: 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_requested: str
|
||||
runtime_override_applied: bool
|
||||
@@ -237,7 +226,7 @@ def inspect_login_environment(
|
||||
and selection.kind in {"proton7", "proton7-container"}
|
||||
else None
|
||||
)
|
||||
game_root = _active_game_root(paths, settings)
|
||||
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)
|
||||
@@ -252,12 +241,6 @@ def inspect_login_environment(
|
||||
)
|
||||
game_profile = classify_game_profile(game_exe_sha256, game_dll_sha256)
|
||||
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_ready = _prefix_ready(prefix)
|
||||
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
|
||||
else None
|
||||
)
|
||||
latest_log = latest_native_game_log(paths, settings)
|
||||
latest_log = latest_native_game_log(paths)
|
||||
warnings = _diagnostic_warnings(
|
||||
probe,
|
||||
game_profile,
|
||||
@@ -333,13 +316,6 @@ def inspect_login_environment(
|
||||
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")),
|
||||
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_requested=runtime_requested,
|
||||
runtime_override_applied=runtime_override is not None,
|
||||
@@ -473,7 +449,7 @@ def classify_game_profile(
|
||||
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."""
|
||||
|
||||
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
|
||||
|
||||
|
||||
def _active_game_root(paths: AppPaths, settings: Settings) -> Path:
|
||||
def _active_game_root(paths: AppPaths) -> Path:
|
||||
if paths.runtime_view.is_dir():
|
||||
return paths.runtime_view
|
||||
return paths.source_game
|
||||
|
||||
Binary file not shown.
@@ -19,7 +19,6 @@ from ptdiag.assbin import (
|
||||
)
|
||||
|
||||
from .paths import AppPaths
|
||||
from .world_fix import V21_BINARY_HASHES
|
||||
|
||||
|
||||
WHISPERING_VALE_RELATIVE = Path("game/maps/WhisperingVale.rovug")
|
||||
@@ -35,7 +34,10 @@ COMPATIBILITY_BINARY_HASHES = {
|
||||
"Game.exe": "3cc0980aac0f65b379ff340ec61750498fb9f620fb6c88aa66281e8ab27023a5",
|
||||
"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 = (
|
||||
"0be11b06ccf9be747e32b195c8d5980062f446d7d3a0a662ebfe0e602a01c538"
|
||||
)
|
||||
@@ -87,8 +89,6 @@ class RuntimeCompatibility:
|
||||
def inspect_runtime_compatibility(
|
||||
paths: AppPaths,
|
||||
source_game: Path,
|
||||
*,
|
||||
use_world_fix: bool = True,
|
||||
) -> RuntimeCompatibility:
|
||||
"""Check whether ``prepare_runtime_view`` can recognize the installed release.
|
||||
|
||||
@@ -109,9 +109,6 @@ def inspect_runtime_compatibility(
|
||||
inspection = inspect_wrapped_assbin(source_scene)
|
||||
source_scene_sha256 = inspection.file_sha256
|
||||
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)
|
||||
except (OSError, ValueError, OverlayError) as exc:
|
||||
reason = str(exc).strip() or type(exc).__name__
|
||||
@@ -136,8 +133,6 @@ def inspect_runtime_compatibility(
|
||||
def prepare_runtime_view(
|
||||
paths: AppPaths,
|
||||
source_game: Path,
|
||||
*,
|
||||
use_world_fix: bool = True,
|
||||
) -> OverlayResult:
|
||||
"""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)
|
||||
action = _scene_action(inspection)
|
||||
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,
|
||||
game_exe_source,
|
||||
|
||||
@@ -144,6 +144,12 @@ 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."""
|
||||
|
||||
@@ -17,11 +17,6 @@ from .command import CommandOutcome, CommandRunner, OutputLineFilter
|
||||
from .gamebox import build_gamebox_command
|
||||
from .paths import AppPaths
|
||||
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"
|
||||
@@ -289,8 +284,6 @@ def run_game(
|
||||
runner: CommandRunner,
|
||||
*,
|
||||
use_gamebox: bool = True,
|
||||
use_world_fix: bool = False,
|
||||
world_fix_status_target: WorldFixStatusTarget | None = None,
|
||||
timeout_seconds: float = 12 * 60 * 60,
|
||||
check: bool = True,
|
||||
extra_environment: Mapping[str, str] | None = None,
|
||||
@@ -298,38 +291,21 @@ def run_game(
|
||||
) -> CommandOutcome:
|
||||
if not paths.runtime_view.is_dir() and not runner.dry_run:
|
||||
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":
|
||||
game_command = [
|
||||
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)
|
||||
elif selection.kind == "proton7-container":
|
||||
if not selection.proton_path:
|
||||
raise RuntimeErrorPT("Proton script path is missing")
|
||||
game_command = [
|
||||
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(
|
||||
paths,
|
||||
selection,
|
||||
@@ -344,14 +320,9 @@ def run_game(
|
||||
"ptlinux.proton_compat",
|
||||
selection.proton_path,
|
||||
"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(
|
||||
paths,
|
||||
selection,
|
||||
|
||||
@@ -6,6 +6,7 @@ import base64
|
||||
from collections import deque
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import queue
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -21,6 +22,7 @@ from .install_status import (
|
||||
PrimaryAction,
|
||||
inspect_game_installation,
|
||||
)
|
||||
from .import_state import game_import_requires_verification
|
||||
from .news import (
|
||||
Broadcast,
|
||||
NewsArticle,
|
||||
@@ -63,6 +65,34 @@ class DashboardError(RuntimeError):
|
||||
"""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:
|
||||
"""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
|
||||
local_status = inspect_game_installation(paths)
|
||||
refresh_primary_action(update_activity=update_activity)
|
||||
if not local_status.installed:
|
||||
if not local_status.installed or game_import_requires_verification(paths):
|
||||
return
|
||||
|
||||
def worker() -> None:
|
||||
@@ -1279,8 +1309,10 @@ def run_dashboard(paths: AppPaths) -> None:
|
||||
if value == 0:
|
||||
append_log("Launcher update activated; restarting the dashboard.")
|
||||
try:
|
||||
restart_command, restart_environment = launcher_restart_spec(paths)
|
||||
subprocess.Popen(
|
||||
command_for("gui"),
|
||||
restart_command,
|
||||
env=restart_environment,
|
||||
close_fds=True,
|
||||
start_new_session=True,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user