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

@@ -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,
)

View File

@@ -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(),

View File

@@ -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)},
)

View File

@@ -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
):

View File

@@ -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.

View File

@@ -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,

View File

@@ -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."""

View File

@@ -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,

View File

@@ -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,
)

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