diff --git a/packaging/pristontale-eu b/packaging/pristontale-eu index fe35fc0..c19fd81 100755 --- a/packaging/pristontale-eu +++ b/packaging/pristontale-eu @@ -33,8 +33,9 @@ fi # Never let an unpacked older installer in the caller's current directory # shadow the selected installed release. Children started by the dashboard -# inherit the same safe-path policy and exact package root. +# inherit the same safe-path policy, package root, and no-bytecode policy. export PYTHONSAFEPATH=1 +export PYTHONDONTWRITEBYTECODE=1 export PYTHONPATH="${release_root}" exec "$python_bin" -B -P -m ptlinux "$@" diff --git a/ptlinux/cli.py b/ptlinux/cli.py index 3016c69..9ce31b2 100644 --- a/ptlinux/cli.py +++ b/ptlinux/cli.py @@ -21,7 +21,12 @@ from ptdiag.logs import ( from . import __version__ from .command import CommandError, CommandRunner -from .depot import SyncProgress, sync_managed_game +from .depot import ( + SyncProgress, + read_cached_depot_launch_catalog, + select_depot_start_option, + sync_managed_game, +) from .doctor import inspect_host from .download import OFFICIAL_LAUNCHER_URL, fetch_official_launcher from .importer import GameImportResult, discover_existing_game, import_existing_game @@ -148,6 +153,10 @@ def build_parser() -> argparse.ArgumentParser: play = subparsers.add_parser("play", help="launch the game in the managed Proton window") play.add_argument("--game-dir", type=Path) + play.add_argument( + "--realm", + help="launch an available realm from the last official depot manifest", + ) play.add_argument("--no-box", action="store_true", help="launch without X11 window styling") play.add_argument( "--publisher-dll", @@ -297,6 +306,7 @@ def main(argv: Sequence[str] | None = None) -> int: arguments.dry_run, arguments.skip_runtime_setup, arguments.json, + realm=arguments.realm, ) if command == "login-trace": return _login_trace( @@ -708,6 +718,8 @@ def _play( dry_run: bool, skip_runtime_setup: bool, as_json: bool, + *, + realm: str | None = None, ) -> int: with _play_guard(paths, dry_run) as play_lease: return _play_locked( @@ -717,6 +729,7 @@ def _play( dry_run, skip_runtime_setup, as_json, + realm=realm, reuse_prepared=not play_lease.preparation_allowed, before_game=play_lease.allow_shared_clients, ) @@ -730,6 +743,7 @@ def _play_locked( skip_runtime_setup: bool, as_json: bool, *, + realm: str | None = None, reuse_prepared: bool = False, before_game: Callable[[], None] | None = None, ) -> int: @@ -739,6 +753,10 @@ def _play_locked( "run pristontale-eu update before playing" ) settings = _load_or_default_settings(paths, persist=not dry_run) + start_option = select_depot_start_option( + read_cached_depot_launch_catalog(paths.depot_launch_catalog_cache), + realm or settings.realm, + ) source = _source_game(paths, settings, game_dir) overlay = None _print_operation_status( @@ -799,6 +817,7 @@ def _play_locked( settings, selection, runner, + start_option=start_option, use_gamebox=not no_box, ) except KeyboardInterrupt: @@ -826,6 +845,7 @@ def _play_locked( "ok": outcome.returncode == 0, "dry_run": dry_run, "runtime": asdict(selection), + "realm": asdict(start_option), "runtime_view": asdict(overlay) if overlay else None, "setup_commands": [asdict(result) for result in setup_results], "game": asdict(outcome), diff --git a/ptlinux/depot.py b/ptlinux/depot.py index 960a13a..ce45ac7 100644 --- a/ptlinux/depot.py +++ b/ptlinux/depot.py @@ -73,6 +73,7 @@ ALWAYS_VERIFY_PATHS = frozenset( ) SHA1_PATTERN = re.compile(r"^[0-9a-fA-F]{40}$") CONTENT_RANGE_PATTERN = re.compile(r"^bytes\s+(\d+)-(\d+)/(\d+)$", re.IGNORECASE) +DEPOT_LAUNCH_CATALOG_SCHEMA = 1 class DepotError(RuntimeError): @@ -105,6 +106,22 @@ class CreateIfMissing: content_base64: str +@dataclass(frozen=True) +class DepotStartOption: + """One publisher-advertised realm launch target.""" + + name: str + path: str + arguments: str + + +DEFAULT_DEPOT_START_OPTION = DepotStartOption( + name="Babel", + path="Game.exe", + arguments="PTEU_REBORN", +) + + @dataclass(frozen=True) class DepotSnapshot: """The current, verified shape of the public game depot.""" @@ -117,6 +134,101 @@ class DepotSnapshot: files: tuple[DepotFile, ...] deleted_files: tuple[str, ...] create_if_missing: tuple[CreateIfMissing, ...] + start_options: tuple[DepotStartOption, ...] = (DEFAULT_DEPOT_START_OPTION,) + default_start_option: str = DEFAULT_DEPOT_START_OPTION.name + + +@dataclass(frozen=True) +class DepotLaunchCatalog: + """Small cached subset of a depot manifest needed for offline Play.""" + + build: int + start_options: tuple[DepotStartOption, ...] + default_start_option: str + + +def launch_catalog_from_snapshot(snapshot: DepotSnapshot) -> DepotLaunchCatalog: + return DepotLaunchCatalog( + build=snapshot.build, + start_options=snapshot.start_options, + default_start_option=snapshot.default_start_option, + ) + + +def select_depot_start_option( + catalog: DepotLaunchCatalog | None, + preferred_realm: str | None = None, +) -> DepotStartOption: + """Choose a published realm, falling back to Babel when possible.""" + + current = catalog or DepotLaunchCatalog( + build=0, + start_options=(DEFAULT_DEPOT_START_OPTION,), + default_start_option=DEFAULT_DEPOT_START_OPTION.name, + ) + if preferred_realm: + preferred = preferred_realm.casefold() + for option in current.start_options: + if option.name.casefold() == preferred: + return option + for option in current.start_options: + if option.name.casefold() == DEFAULT_DEPOT_START_OPTION.name.casefold(): + return option + for option in current.start_options: + if option.name.casefold() == current.default_start_option.casefold(): + return option + return current.start_options[0] + + +def cache_depot_launch_catalog(path: Path, snapshot: DepotSnapshot) -> None: + """Cache only publisher-advertised launch metadata for network-free Play.""" + + catalog = launch_catalog_from_snapshot(snapshot) + _write_json( + path, + { + "schema": DEPOT_LAUNCH_CATALOG_SCHEMA, + "build": catalog.build, + "default_start_option": catalog.default_start_option, + "start_options": [asdict(option) for option in catalog.start_options], + }, + ) + + +def read_cached_depot_launch_catalog(path: Path) -> DepotLaunchCatalog | None: + """Read the last valid launch catalog without performing network I/O.""" + + try: + value = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(value, dict): + return None + if value.get("schema") != DEPOT_LAUNCH_CATALOG_SCHEMA: + return None + build = _positive_int(value.get("build"), "cached depot build") + raw_options = value.get("start_options") + if not isinstance(raw_options, list): + return None + manifest_options = [ + { + "Name": item.get("name"), + "Path": item.get("path"), + "Arguments": item.get("arguments"), + } + if isinstance(item, dict) + else item + for item in raw_options + ] + options, default_name = _parse_start_options( + manifest_options, + value.get("default_start_option"), + ) + except (OSError, UnicodeError, json.JSONDecodeError, DepotError): + return None + return DepotLaunchCatalog( + build=build, + start_options=options, + default_start_option=default_name, + ) @dataclass(frozen=True) @@ -579,6 +691,7 @@ def sync_managed_game( quarantine=quarantine, ) _write_json(paths.native_depot_state, asdict(result)) + cache_depot_launch_catalog(paths.depot_launch_catalog_cache, snapshot) _emit_progress( progress, "complete", @@ -913,6 +1026,19 @@ def _parse_snapshot( deleted = _parse_deleted_paths(build_manifest.get("df"), seen_paths) create_if_missing = _parse_create_if_missing(depot_manifest.get("CreateIfMissing")) + start_options, default_start_option = _parse_start_options( + depot_manifest.get("StartOptions"), + depot_manifest.get("DefaultStartOption"), + ) + canonical_file_paths = {file.path.casefold(): file.path for file in files} + start_options = tuple( + DepotStartOption( + name=option.name, + path=canonical_file_paths.get(option.path.casefold(), option.path), + arguments=option.arguments, + ) + for option in start_options + ) return DepotSnapshot( region=region, server=server, @@ -922,6 +1048,8 @@ def _parse_snapshot( files=tuple(files), deleted_files=deleted, create_if_missing=create_if_missing, + start_options=start_options, + default_start_option=default_start_option, ) @@ -966,6 +1094,74 @@ def _parse_create_if_missing(value: object) -> tuple[CreateIfMissing, ...]: return tuple(entries) +def _parse_start_options( + value: object, + default_value: object, +) -> tuple[tuple[DepotStartOption, ...], str]: + """Validate the launch targets published by the official depot manifest.""" + + # Older test fixtures and historical manifests did not expose StartOptions. + # Babel remains the launcher-safe compatibility default for that case. + if value is None: + return (DEFAULT_DEPOT_START_OPTION,), DEFAULT_DEPOT_START_OPTION.name + if not isinstance(value, list) or not value: + raise DepotError("StartOptions must be a non-empty array") + options: list[DepotStartOption] = [] + seen_names: set[str] = set() + for item in value: + if not isinstance(item, dict): + raise DepotError("StartOptions contains a non-object entry") + name = _start_option_text(item.get("Name"), "StartOptions Name", 64) + folded_name = name.casefold() + if folded_name in seen_names: + raise DepotError(f"StartOptions has duplicate realm name: {name}") + seen_names.add(folded_name) + path = _normalise_relative_path(item.get("Path"), f"StartOptions path for {name}") + if PurePosixPath(path).suffix.casefold() != ".exe": + raise DepotError(f"StartOptions path is not an executable for {name}: {path}") + arguments = _start_option_text( + item.get("Arguments"), + f"StartOptions arguments for {name}", + 2048, + ) + options.append(DepotStartOption(name=name, path=path, arguments=arguments)) + + if default_value is None: + default_name = next( + ( + option.name + for option in options + if option.name.casefold() == DEFAULT_DEPOT_START_OPTION.name.casefold() + ), + options[0].name, + ) + else: + default_name = _start_option_text( + default_value, + "DefaultStartOption", + 64, + ) + matching_default = next( + (option.name for option in options if option.name.casefold() == default_name.casefold()), + None, + ) + if matching_default is None: + raise DepotError( + f"DefaultStartOption does not match an advertised realm: {default_name}" + ) + default_name = matching_default + return tuple(options), default_name + + +def _start_option_text(value: object, field: str, maximum_length: int) -> str: + if not isinstance(value, str): + raise DepotError(f"{field} must be a string") + text = value.strip() + if not text or len(text) > maximum_length or any(ord(character) < 32 for character in text): + raise DepotError(f"invalid {field}") + return text + + def _decode_create_if_missing(entry: CreateIfMissing) -> bytes: try: content = b64decode(entry.content_base64, validate=True) diff --git a/ptlinux/paths.py b/ptlinux/paths.py index da90c08..07c7815 100644 --- a/ptlinux/paths.py +++ b/ptlinux/paths.py @@ -156,6 +156,12 @@ class AppPaths: return self.state / "native-depot.json" + @property + def depot_launch_catalog_cache(self) -> Path: + """Last official realm choices used for network-free game launches.""" + + return self.cache / "depot-launch-catalog.json" + @property def news_cache(self) -> Path: return self.cache / "news.json"