Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ed0c278a8b | |||
| 094c881798 | |||
| bc87f10661 | |||
| 562c924809 | |||
| 94a5e81555 | |||
| 94f2260f43 | |||
| 994fda5cc7 |
@@ -1,6 +1,6 @@
|
|||||||
# PristonTale EU for Linux
|
# PristonTale EU for Linux
|
||||||
|
|
||||||
Version 0.6.5 installs, updates, repairs, and launches PristonTale EU through a
|
Version 0.7.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.
|
||||||
|
|
||||||
@@ -15,11 +15,11 @@ Then run:
|
|||||||
```text
|
```text
|
||||||
openssl pkey -pubin -in launcher-update-public.pem -outform DER | sha256sum
|
openssl pkey -pubin -in launcher-update-public.pem -outform DER | sha256sum
|
||||||
openssl dgst -sha256 -verify launcher-update-public.pem \
|
openssl dgst -sha256 -verify launcher-update-public.pem \
|
||||||
-signature pristontale-eu-linux-installer-v0-6-5.tar.gz.sig \
|
-signature pristontale-eu-linux-installer-v0-7-0.tar.gz.sig \
|
||||||
pristontale-eu-linux-installer-v0-6-5.tar.gz
|
pristontale-eu-linux-installer-v0-7-0.tar.gz
|
||||||
sha256sum -c pristontale-eu-linux-installer-v0-6-5.tar.gz.sha256
|
sha256sum -c pristontale-eu-linux-installer-v0-7-0.tar.gz.sha256
|
||||||
tar -xzf pristontale-eu-linux-installer-v0-6-5.tar.gz
|
tar -xzf pristontale-eu-linux-installer-v0-7-0.tar.gz
|
||||||
cd pristontale-eu-linux-installer-v0-6-5
|
cd pristontale-eu-linux-installer-v0-7-0
|
||||||
./install.sh
|
./install.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -8,3 +8,4 @@ Terminal=false
|
|||||||
Categories=Game;RolePlaying;
|
Categories=Game;RolePlaying;
|
||||||
Keywords=PristonTale;MMORPG;Wine;Proton;
|
Keywords=PristonTale;MMORPG;Wine;Proton;
|
||||||
StartupNotify=true
|
StartupNotify=true
|
||||||
|
StartupWMClass=PristonTaleEU
|
||||||
|
|||||||
@@ -33,8 +33,9 @@ fi
|
|||||||
|
|
||||||
# Never let an unpacked older installer in the caller's current directory
|
# Never let an unpacked older installer in the caller's current directory
|
||||||
# shadow the selected installed release. Children started by the dashboard
|
# 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 PYTHONSAFEPATH=1
|
||||||
|
export PYTHONDONTWRITEBYTECODE=1
|
||||||
export PYTHONPATH="${release_root}"
|
export PYTHONPATH="${release_root}"
|
||||||
|
|
||||||
exec "$python_bin" -B -P -m ptlinux "$@"
|
exec "$python_bin" -B -P -m ptlinux "$@"
|
||||||
|
|||||||
@@ -4,4 +4,4 @@ from .paths import AppPaths
|
|||||||
|
|
||||||
__all__ = ["AppPaths", "__version__"]
|
__all__ = ["AppPaths", "__version__"]
|
||||||
|
|
||||||
__version__ = "0.6.5"
|
__version__ = "0.7.0"
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import json
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
from typing import Sequence
|
from typing import Callable, Sequence
|
||||||
|
|
||||||
from ptdiag.logs import (
|
from ptdiag.logs import (
|
||||||
LogFileState,
|
LogFileState,
|
||||||
@@ -21,7 +21,12 @@ from ptdiag.logs import (
|
|||||||
|
|
||||||
from . import __version__
|
from . import __version__
|
||||||
from .command import CommandError, CommandRunner
|
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 .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
|
||||||
@@ -33,12 +38,13 @@ from .launcher import (
|
|||||||
launch_official_updater,
|
launch_official_updater,
|
||||||
prepare_launcher_binary,
|
prepare_launcher_binary,
|
||||||
)
|
)
|
||||||
from .locking import launcher_operation_lock
|
from .locking import LauncherPlayLease, launcher_operation_lock, launcher_play_lock
|
||||||
from .login_diagnostics import inspect_login_environment
|
from .login_diagnostics import inspect_login_environment
|
||||||
from .login_trace import DEFAULT_TRACE_TIMEOUT_SECONDS, LOGIN_TRACE_DISABLED_REASON
|
from .login_trace import DEFAULT_TRACE_TIMEOUT_SECONDS, LOGIN_TRACE_DISABLED_REASON
|
||||||
from .overlay import cache_compatibility_binaries, prepare_runtime_view
|
from .overlay import cache_compatibility_binaries, prepare_runtime_view
|
||||||
from .paths import AppPaths
|
from .paths import AppPaths
|
||||||
from .runtime import (
|
from .runtime import (
|
||||||
|
game_runtime_ready,
|
||||||
prepare_game_runtime,
|
prepare_game_runtime,
|
||||||
run_game,
|
run_game,
|
||||||
select_runtime,
|
select_runtime,
|
||||||
@@ -147,6 +153,10 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
|
|
||||||
play = subparsers.add_parser("play", help="launch the game in the managed Proton window")
|
play = subparsers.add_parser("play", help="launch the game in the managed Proton window")
|
||||||
play.add_argument("--game-dir", type=Path)
|
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("--no-box", action="store_true", help="launch without X11 window styling")
|
||||||
play.add_argument(
|
play.add_argument(
|
||||||
"--publisher-dll",
|
"--publisher-dll",
|
||||||
@@ -296,6 +306,7 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|||||||
arguments.dry_run,
|
arguments.dry_run,
|
||||||
arguments.skip_runtime_setup,
|
arguments.skip_runtime_setup,
|
||||||
arguments.json,
|
arguments.json,
|
||||||
|
realm=arguments.realm,
|
||||||
)
|
)
|
||||||
if command == "login-trace":
|
if command == "login-trace":
|
||||||
return _login_trace(
|
return _login_trace(
|
||||||
@@ -687,6 +698,7 @@ 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:
|
||||||
|
with _operation_guard(paths, "prepare", False):
|
||||||
if game_dir is None and game_import_requires_verification(paths):
|
if game_dir is None and game_import_requires_verification(paths):
|
||||||
raise LauncherError(
|
raise LauncherError(
|
||||||
"the imported game has not completed its full publisher verification; "
|
"the imported game has not completed its full publisher verification; "
|
||||||
@@ -706,8 +718,10 @@ def _play(
|
|||||||
dry_run: bool,
|
dry_run: bool,
|
||||||
skip_runtime_setup: bool,
|
skip_runtime_setup: bool,
|
||||||
as_json: bool,
|
as_json: bool,
|
||||||
|
*,
|
||||||
|
realm: str | None = None,
|
||||||
) -> int:
|
) -> int:
|
||||||
with _operation_guard(paths, "play", dry_run):
|
with _play_guard(paths, dry_run) as play_lease:
|
||||||
return _play_locked(
|
return _play_locked(
|
||||||
paths,
|
paths,
|
||||||
game_dir,
|
game_dir,
|
||||||
@@ -715,6 +729,9 @@ def _play(
|
|||||||
dry_run,
|
dry_run,
|
||||||
skip_runtime_setup,
|
skip_runtime_setup,
|
||||||
as_json,
|
as_json,
|
||||||
|
realm=realm,
|
||||||
|
reuse_prepared=not play_lease.preparation_allowed,
|
||||||
|
before_game=play_lease.allow_shared_clients,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -725,6 +742,10 @@ def _play_locked(
|
|||||||
dry_run: bool,
|
dry_run: bool,
|
||||||
skip_runtime_setup: bool,
|
skip_runtime_setup: bool,
|
||||||
as_json: bool,
|
as_json: bool,
|
||||||
|
*,
|
||||||
|
realm: str | None = None,
|
||||||
|
reuse_prepared: bool = False,
|
||||||
|
before_game: Callable[[], None] | None = None,
|
||||||
) -> int:
|
) -> int:
|
||||||
if game_dir is None and game_import_requires_verification(paths):
|
if game_dir is None and game_import_requires_verification(paths):
|
||||||
raise LauncherError(
|
raise LauncherError(
|
||||||
@@ -732,18 +753,55 @@ def _play_locked(
|
|||||||
"run pristontale-eu update before playing"
|
"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)
|
||||||
|
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)
|
source = _source_game(paths, settings, game_dir)
|
||||||
overlay = None
|
overlay = None
|
||||||
_print_operation_status(as_json, 1, 4, "Preparing the managed game view…")
|
_print_operation_status(
|
||||||
if not dry_run:
|
as_json,
|
||||||
|
1,
|
||||||
|
4,
|
||||||
|
(
|
||||||
|
"Reusing the active managed game view…"
|
||||||
|
if reuse_prepared
|
||||||
|
else "Preparing the managed game view…"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if reuse_prepared:
|
||||||
|
if game_dir is not None:
|
||||||
|
raise LauncherError(
|
||||||
|
"an additional client cannot replace the active managed game view; "
|
||||||
|
"close every client before using --game-dir"
|
||||||
|
)
|
||||||
|
if not paths.runtime_view.is_dir():
|
||||||
|
raise LauncherError(
|
||||||
|
"another game client is active, but the prepared runtime view is missing"
|
||||||
|
)
|
||||||
|
elif not dry_run:
|
||||||
overlay = prepare_runtime_view(paths, source)
|
overlay = prepare_runtime_view(paths, source)
|
||||||
_print_operation_status(as_json, 2, 4, "Selecting the configured game runtime…")
|
_print_operation_status(as_json, 2, 4, "Selecting the configured game runtime…")
|
||||||
selection = select_runtime(settings)
|
selection = select_runtime(settings)
|
||||||
runner = _runner(paths, "play", dry_run)
|
runner = _runner(paths, "play", dry_run)
|
||||||
setup_results = []
|
setup_results = []
|
||||||
if not skip_runtime_setup:
|
if reuse_prepared:
|
||||||
|
_print_operation_status(
|
||||||
|
as_json,
|
||||||
|
3,
|
||||||
|
4,
|
||||||
|
"Reusing the active Proton and DirectX runtime…",
|
||||||
|
)
|
||||||
|
if not game_runtime_ready(paths, selection):
|
||||||
|
raise LauncherError(
|
||||||
|
"another game client is active, but its prepared runtime no longer "
|
||||||
|
"matches the current settings; close every client and launch again"
|
||||||
|
)
|
||||||
|
elif not skip_runtime_setup:
|
||||||
_print_operation_status(as_json, 3, 4, "Preparing Proton and DirectX components…")
|
_print_operation_status(as_json, 3, 4, "Preparing Proton and DirectX components…")
|
||||||
setup_results = prepare_game_runtime(paths, selection, runner)
|
setup_results = prepare_game_runtime(paths, selection, runner)
|
||||||
|
else:
|
||||||
|
_print_operation_status(as_json, 3, 4, "Using the existing Proton runtime…")
|
||||||
_print_operation_status(as_json, 4, 4, "Starting the PristonTale game client…")
|
_print_operation_status(as_json, 4, 4, "Starting the PristonTale game client…")
|
||||||
native_log_directory = paths.game_save / "logs"
|
native_log_directory = paths.game_save / "logs"
|
||||||
native_log_baseline = snapshot_native_logs(
|
native_log_baseline = snapshot_native_logs(
|
||||||
@@ -751,12 +809,15 @@ def _play_locked(
|
|||||||
patterns=("log_*.txt",),
|
patterns=("log_*.txt",),
|
||||||
)
|
)
|
||||||
game_started_ns = time.time_ns()
|
game_started_ns = time.time_ns()
|
||||||
|
if before_game is not None:
|
||||||
|
before_game()
|
||||||
try:
|
try:
|
||||||
outcome = run_game(
|
outcome = run_game(
|
||||||
paths,
|
paths,
|
||||||
settings,
|
settings,
|
||||||
selection,
|
selection,
|
||||||
runner,
|
runner,
|
||||||
|
start_option=start_option,
|
||||||
use_gamebox=not no_box,
|
use_gamebox=not no_box,
|
||||||
)
|
)
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
@@ -784,6 +845,7 @@ def _play_locked(
|
|||||||
"ok": outcome.returncode == 0,
|
"ok": outcome.returncode == 0,
|
||||||
"dry_run": dry_run,
|
"dry_run": dry_run,
|
||||||
"runtime": asdict(selection),
|
"runtime": asdict(selection),
|
||||||
|
"realm": asdict(start_option),
|
||||||
"runtime_view": asdict(overlay) if overlay else None,
|
"runtime_view": asdict(overlay) if overlay else None,
|
||||||
"setup_commands": [asdict(result) for result in setup_results],
|
"setup_commands": [asdict(result) for result in setup_results],
|
||||||
"game": asdict(outcome),
|
"game": asdict(outcome),
|
||||||
@@ -1000,10 +1062,19 @@ def _load_or_default_settings(paths: AppPaths, *, persist: bool) -> Settings:
|
|||||||
|
|
||||||
|
|
||||||
def _runner(paths: AppPaths, operation: str, dry_run: bool) -> CommandRunner:
|
def _runner(paths: AppPaths, operation: str, dry_run: bool) -> CommandRunner:
|
||||||
timestamp = datetime.now().astimezone().strftime("%Y%m%d-%H%M%S")
|
timestamp = datetime.now().astimezone().strftime("%Y%m%d-%H%M%S-%f")
|
||||||
return CommandRunner(paths.logs / f"{timestamp}-{operation}.jsonl", dry_run=dry_run)
|
return CommandRunner(paths.logs / f"{timestamp}-{operation}.jsonl", dry_run=dry_run)
|
||||||
|
|
||||||
|
|
||||||
|
def _play_guard(
|
||||||
|
paths: AppPaths,
|
||||||
|
dry_run: bool,
|
||||||
|
) -> AbstractContextManager[LauncherPlayLease]:
|
||||||
|
if dry_run:
|
||||||
|
return nullcontext(LauncherPlayLease.dry_run())
|
||||||
|
return launcher_play_lock(paths.launcher_operation_lock)
|
||||||
|
|
||||||
|
|
||||||
def _operation_guard(
|
def _operation_guard(
|
||||||
paths: AppPaths,
|
paths: AppPaths,
|
||||||
operation: str,
|
operation: str,
|
||||||
|
|||||||
196
ptlinux/depot.py
196
ptlinux/depot.py
@@ -73,6 +73,7 @@ ALWAYS_VERIFY_PATHS = frozenset(
|
|||||||
)
|
)
|
||||||
SHA1_PATTERN = re.compile(r"^[0-9a-fA-F]{40}$")
|
SHA1_PATTERN = re.compile(r"^[0-9a-fA-F]{40}$")
|
||||||
CONTENT_RANGE_PATTERN = re.compile(r"^bytes\s+(\d+)-(\d+)/(\d+)$", re.IGNORECASE)
|
CONTENT_RANGE_PATTERN = re.compile(r"^bytes\s+(\d+)-(\d+)/(\d+)$", re.IGNORECASE)
|
||||||
|
DEPOT_LAUNCH_CATALOG_SCHEMA = 1
|
||||||
|
|
||||||
|
|
||||||
class DepotError(RuntimeError):
|
class DepotError(RuntimeError):
|
||||||
@@ -105,6 +106,22 @@ class CreateIfMissing:
|
|||||||
content_base64: str
|
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)
|
@dataclass(frozen=True)
|
||||||
class DepotSnapshot:
|
class DepotSnapshot:
|
||||||
"""The current, verified shape of the public game depot."""
|
"""The current, verified shape of the public game depot."""
|
||||||
@@ -117,6 +134,101 @@ class DepotSnapshot:
|
|||||||
files: tuple[DepotFile, ...]
|
files: tuple[DepotFile, ...]
|
||||||
deleted_files: tuple[str, ...]
|
deleted_files: tuple[str, ...]
|
||||||
create_if_missing: tuple[CreateIfMissing, ...]
|
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)
|
@dataclass(frozen=True)
|
||||||
@@ -579,6 +691,7 @@ def sync_managed_game(
|
|||||||
quarantine=quarantine,
|
quarantine=quarantine,
|
||||||
)
|
)
|
||||||
_write_json(paths.native_depot_state, asdict(result))
|
_write_json(paths.native_depot_state, asdict(result))
|
||||||
|
cache_depot_launch_catalog(paths.depot_launch_catalog_cache, snapshot)
|
||||||
_emit_progress(
|
_emit_progress(
|
||||||
progress,
|
progress,
|
||||||
"complete",
|
"complete",
|
||||||
@@ -913,6 +1026,19 @@ def _parse_snapshot(
|
|||||||
|
|
||||||
deleted = _parse_deleted_paths(build_manifest.get("df"), seen_paths)
|
deleted = _parse_deleted_paths(build_manifest.get("df"), seen_paths)
|
||||||
create_if_missing = _parse_create_if_missing(depot_manifest.get("CreateIfMissing"))
|
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(
|
return DepotSnapshot(
|
||||||
region=region,
|
region=region,
|
||||||
server=server,
|
server=server,
|
||||||
@@ -922,6 +1048,8 @@ def _parse_snapshot(
|
|||||||
files=tuple(files),
|
files=tuple(files),
|
||||||
deleted_files=deleted,
|
deleted_files=deleted,
|
||||||
create_if_missing=create_if_missing,
|
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)
|
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:
|
def _decode_create_if_missing(entry: CreateIfMissing) -> bytes:
|
||||||
try:
|
try:
|
||||||
content = b64decode(entry.content_base64, validate=True)
|
content = b64decode(entry.content_base64, validate=True)
|
||||||
|
|||||||
@@ -10,8 +10,13 @@ import sys
|
|||||||
import time
|
import time
|
||||||
from collections.abc import Collection, Iterable, Sequence
|
from collections.abc import Collection, Iterable, Sequence
|
||||||
|
|
||||||
|
from .desktop_identity import (
|
||||||
|
APPLICATION_NAME,
|
||||||
|
APPLICATION_WM_CLASS,
|
||||||
|
APPLICATION_WM_INSTANCE,
|
||||||
|
)
|
||||||
|
|
||||||
WINDOW_TITLE = "PristonTale EU"
|
WINDOW_TITLE = APPLICATION_NAME
|
||||||
WINDOW_DISCOVERY_TIMEOUT_SECONDS = 60.0
|
WINDOW_DISCOVERY_TIMEOUT_SECONDS = 60.0
|
||||||
WINDOW_READY_DELAY_SECONDS = 0.5
|
WINDOW_READY_DELAY_SECONDS = 0.5
|
||||||
_X_ERROR_HANDLER = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_void_p, ctypes.c_void_p)
|
_X_ERROR_HANDLER = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_void_p, ctypes.c_void_p)
|
||||||
@@ -66,6 +71,13 @@ class _MotifWmHints(ctypes.Structure):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class _XClassHint(ctypes.Structure):
|
||||||
|
_fields_ = [
|
||||||
|
("res_name", ctypes.c_char_p),
|
||||||
|
("res_class", ctypes.c_char_p),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class _X11:
|
class _X11:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
library_path = ctypes.util.find_library("X11") or "libX11.so.6"
|
library_path = ctypes.util.find_library("X11") or "libX11.so.6"
|
||||||
@@ -135,6 +147,33 @@ class _X11:
|
|||||||
self.library.XMapRaised(self.display, window)
|
self.library.XMapRaised(self.display, window)
|
||||||
self.library.XFlush(self.display)
|
self.library.XFlush(self.display)
|
||||||
|
|
||||||
|
def set_window_identity(
|
||||||
|
self,
|
||||||
|
window: int,
|
||||||
|
*,
|
||||||
|
resource_name: str = APPLICATION_WM_INSTANCE,
|
||||||
|
resource_class: str = APPLICATION_WM_CLASS,
|
||||||
|
) -> None:
|
||||||
|
"""Replace Proton's generic steamapp identity before taskbar matching."""
|
||||||
|
|
||||||
|
for label, value in (
|
||||||
|
("resource name", resource_name),
|
||||||
|
("resource class", resource_class),
|
||||||
|
):
|
||||||
|
if not value or "\x00" in value:
|
||||||
|
raise GameboxRuntimeError(f"invalid X11 {label}")
|
||||||
|
hints = _XClassHint(
|
||||||
|
res_name=resource_name.encode("utf-8"),
|
||||||
|
res_class=resource_class.encode("utf-8"),
|
||||||
|
)
|
||||||
|
if self.library.XSetClassHint(
|
||||||
|
self.display,
|
||||||
|
window,
|
||||||
|
ctypes.byref(hints),
|
||||||
|
) == 0:
|
||||||
|
raise GameboxRuntimeError("could not set the game window identity")
|
||||||
|
self.library.XSync(self.display, 0)
|
||||||
|
|
||||||
def suppress_wm_ping(self, window: int) -> bool:
|
def suppress_wm_ping(self, window: int) -> bool:
|
||||||
"""Stop the WM hang prompt for one window, preserving its other protocols."""
|
"""Stop the WM hang prompt for one window, preserving its other protocols."""
|
||||||
ping_atom = int(self.library.XInternAtom(self.display, b"_NET_WM_PING", 0))
|
ping_atom = int(self.library.XInternAtom(self.display, b"_NET_WM_PING", 0))
|
||||||
@@ -265,6 +304,12 @@ class _X11:
|
|||||||
ctypes.c_int,
|
ctypes.c_int,
|
||||||
]
|
]
|
||||||
self.library.XChangeProperty.restype = ctypes.c_int
|
self.library.XChangeProperty.restype = ctypes.c_int
|
||||||
|
self.library.XSetClassHint.argtypes = [
|
||||||
|
ctypes.c_void_p,
|
||||||
|
ctypes.c_ulong,
|
||||||
|
ctypes.POINTER(_XClassHint),
|
||||||
|
]
|
||||||
|
self.library.XSetClassHint.restype = ctypes.c_int
|
||||||
self.library.XGetWMProtocols.argtypes = [
|
self.library.XGetWMProtocols.argtypes = [
|
||||||
ctypes.c_void_p,
|
ctypes.c_void_p,
|
||||||
ctypes.c_ulong,
|
ctypes.c_ulong,
|
||||||
@@ -325,6 +370,18 @@ def _prepare_game_window(
|
|||||||
mapper.map_window(window, borderless=borderless)
|
mapper.map_window(window, borderless=borderless)
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_game_window_identity(mapper: _X11, window: int) -> None:
|
||||||
|
try:
|
||||||
|
mapper.set_window_identity(window)
|
||||||
|
except Exception as exc:
|
||||||
|
print(
|
||||||
|
f"Warning: could not assign the PristonTale EU taskbar identity ({exc}); "
|
||||||
|
"continuing normally",
|
||||||
|
file=sys.stderr,
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def run_game_window(
|
def run_game_window(
|
||||||
command: Sequence[str],
|
command: Sequence[str],
|
||||||
window_title: str = WINDOW_TITLE,
|
window_title: str = WINDOW_TITLE,
|
||||||
@@ -371,6 +428,7 @@ def run_game_window(
|
|||||||
game_window = window
|
game_window = window
|
||||||
game_title = title
|
game_title = title
|
||||||
game_window_created_at = now
|
game_window_created_at = now
|
||||||
|
_apply_game_window_identity(mapper, window)
|
||||||
print(
|
print(
|
||||||
f"Game window created; preparing it: {title}",
|
f"Game window created; preparing it: {title}",
|
||||||
file=sys.stderr,
|
file=sys.stderr,
|
||||||
|
|||||||
@@ -7,13 +7,51 @@ from datetime import datetime
|
|||||||
import fcntl
|
import fcntl
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Iterator
|
from typing import Iterator, TextIO
|
||||||
|
|
||||||
|
|
||||||
class OperationBusyError(RuntimeError):
|
class OperationBusyError(RuntimeError):
|
||||||
"""Raised when another launcher-prefix operation is still active."""
|
"""Raised when another launcher-prefix operation is still active."""
|
||||||
|
|
||||||
|
|
||||||
|
class LauncherPlayLease:
|
||||||
|
"""Exclusive preparation lease that can become a shared game lease."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
handle: TextIO | None,
|
||||||
|
*,
|
||||||
|
preparation_allowed: bool,
|
||||||
|
record_owned: bool,
|
||||||
|
) -> None:
|
||||||
|
self._handle = handle
|
||||||
|
self.preparation_allowed = preparation_allowed
|
||||||
|
self._record_owned = record_owned
|
||||||
|
self._shared = not preparation_allowed
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def dry_run(cls) -> LauncherPlayLease:
|
||||||
|
return cls(None, preparation_allowed=True, record_owned=False)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def shared(self) -> bool:
|
||||||
|
return self._shared
|
||||||
|
|
||||||
|
def allow_shared_clients(self) -> None:
|
||||||
|
"""Downgrade after all runtime mutations have completed."""
|
||||||
|
|
||||||
|
if self._shared:
|
||||||
|
return
|
||||||
|
if self._handle is not None:
|
||||||
|
_write_owner(self._handle, "play")
|
||||||
|
fcntl.flock(self._handle.fileno(), fcntl.LOCK_SH)
|
||||||
|
self._shared = True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def record_owned(self) -> bool:
|
||||||
|
return self._record_owned
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def launcher_operation_lock(lock_path: Path, operation: str) -> Iterator[None]:
|
def launcher_operation_lock(lock_path: Path, operation: str) -> Iterator[None]:
|
||||||
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -26,13 +64,7 @@ def launcher_operation_lock(lock_path: Path, operation: str) -> Iterator[None]:
|
|||||||
raise OperationBusyError(
|
raise OperationBusyError(
|
||||||
f"launcher files are busy ({owner}); wait for it to finish and try again"
|
f"launcher files are busy ({owner}); wait for it to finish and try again"
|
||||||
) from exc
|
) from exc
|
||||||
handle.seek(0)
|
_write_owner(handle, operation)
|
||||||
handle.truncate()
|
|
||||||
handle.write(
|
|
||||||
f"{operation}, pid={os.getpid()}, "
|
|
||||||
f"started={datetime.now().astimezone().isoformat()}\n"
|
|
||||||
)
|
|
||||||
handle.flush()
|
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
@@ -40,3 +72,67 @@ def launcher_operation_lock(lock_path: Path, operation: str) -> Iterator[None]:
|
|||||||
handle.truncate()
|
handle.truncate()
|
||||||
handle.flush()
|
handle.flush()
|
||||||
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def launcher_play_lock(
|
||||||
|
lock_path: Path,
|
||||||
|
operation: str = "play",
|
||||||
|
) -> Iterator[LauncherPlayLease]:
|
||||||
|
"""Serialize preparation, then allow multiple active game clients.
|
||||||
|
|
||||||
|
The first play process acquires an exclusive lease so it may safely rebuild
|
||||||
|
the runtime view or prefix. It downgrades that lease immediately before
|
||||||
|
starting Game.exe. Later play processes then receive shared, read-only leases,
|
||||||
|
while updates and repairs remain excluded until every client exits.
|
||||||
|
"""
|
||||||
|
|
||||||
|
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with lock_path.open("a+", encoding="utf-8") as handle:
|
||||||
|
try:
|
||||||
|
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
|
except BlockingIOError:
|
||||||
|
try:
|
||||||
|
fcntl.flock(handle.fileno(), fcntl.LOCK_SH | fcntl.LOCK_NB)
|
||||||
|
except BlockingIOError as exc:
|
||||||
|
owner = _read_owner(handle)
|
||||||
|
raise OperationBusyError(
|
||||||
|
f"launcher files are busy ({owner}); wait for it to finish and try again"
|
||||||
|
) from exc
|
||||||
|
lease = LauncherPlayLease(
|
||||||
|
handle,
|
||||||
|
preparation_allowed=False,
|
||||||
|
record_owned=False,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
_write_owner(handle, f"{operation} preparation")
|
||||||
|
lease = LauncherPlayLease(
|
||||||
|
handle,
|
||||||
|
preparation_allowed=True,
|
||||||
|
record_owned=True,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
yield lease
|
||||||
|
finally:
|
||||||
|
# A downgraded owner record remains useful while any secondary shared
|
||||||
|
# lease is active. The next exclusive operation atomically replaces it.
|
||||||
|
if lease.record_owned and not lease.shared:
|
||||||
|
handle.seek(0)
|
||||||
|
handle.truncate()
|
||||||
|
handle.flush()
|
||||||
|
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_owner(handle: TextIO) -> str:
|
||||||
|
handle.seek(0)
|
||||||
|
return handle.read().strip() or "unknown operation"
|
||||||
|
|
||||||
|
|
||||||
|
def _write_owner(handle: TextIO, operation: str) -> None:
|
||||||
|
handle.seek(0)
|
||||||
|
handle.truncate()
|
||||||
|
handle.write(
|
||||||
|
f"{operation}, pid={os.getpid()}, "
|
||||||
|
f"started={datetime.now().astimezone().isoformat()}\n"
|
||||||
|
)
|
||||||
|
handle.flush()
|
||||||
|
|||||||
@@ -156,10 +156,22 @@ class AppPaths:
|
|||||||
|
|
||||||
return self.state / "native-depot.json"
|
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
|
@property
|
||||||
def news_cache(self) -> Path:
|
def news_cache(self) -> Path:
|
||||||
return self.cache / "news.json"
|
return self.cache / "news.json"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def patches_cache(self) -> Path:
|
||||||
|
"""Cached official patch changelog shown beside the news feed."""
|
||||||
|
|
||||||
|
return self.cache / "patches.json"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def broadcast_cache(self) -> Path:
|
def broadcast_cache(self) -> Path:
|
||||||
"""Cached latest announcement from the broadcast service."""
|
"""Cached latest announcement from the broadcast service."""
|
||||||
|
|||||||
@@ -7,19 +7,22 @@ from datetime import datetime
|
|||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path, PurePosixPath
|
||||||
import platform
|
import platform
|
||||||
|
import shlex
|
||||||
import shutil
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
from typing import Mapping, Sequence
|
from typing import Mapping, Sequence
|
||||||
|
|
||||||
from .command import CommandOutcome, CommandRunner, OutputLineFilter
|
from .command import CommandOutcome, CommandRunner, OutputLineFilter
|
||||||
|
from .depot import DEFAULT_DEPOT_START_OPTION, DepotStartOption
|
||||||
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
|
||||||
|
|
||||||
|
|
||||||
WINDOWS_GAME_EXE = r"C:\Program Files (x86)\Pristontale EU\Game.exe"
|
WINDOWS_GAME_DIRECTORY = r"C:\Program Files (x86)\Pristontale EU"
|
||||||
|
WINDOWS_GAME_EXE = rf"{WINDOWS_GAME_DIRECTORY}\Game.exe"
|
||||||
NATIVE_D3D43_DLLS = ("d3dx9_43", "d3dcompiler_43")
|
NATIVE_D3D43_DLLS = ("d3dx9_43", "d3dcompiler_43")
|
||||||
DXVK_D3D9_OVERRIDE = "native,builtin"
|
DXVK_D3D9_OVERRIDE = "native,builtin"
|
||||||
DLL_OVERRIDES_KEY = r"HKCU\Software\Wine\DllOverrides"
|
DLL_OVERRIDES_KEY = r"HKCU\Software\Wine\DllOverrides"
|
||||||
@@ -277,12 +280,23 @@ def prepare_game_runtime(
|
|||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def game_runtime_ready(paths: AppPaths, selection: RuntimeSelection) -> bool:
|
||||||
|
"""Return whether another client may reuse the prepared runtime read-only."""
|
||||||
|
|
||||||
|
if selection.kind == "umu":
|
||||||
|
return native_d3d_ready(paths.umu_prefix)
|
||||||
|
if selection.kind in {"proton7", "proton7-container"}:
|
||||||
|
return _game_runtime_ready(paths, selection)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def run_game(
|
def run_game(
|
||||||
paths: AppPaths,
|
paths: AppPaths,
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
selection: RuntimeSelection,
|
selection: RuntimeSelection,
|
||||||
runner: CommandRunner,
|
runner: CommandRunner,
|
||||||
*,
|
*,
|
||||||
|
start_option: DepotStartOption = DEFAULT_DEPOT_START_OPTION,
|
||||||
use_gamebox: bool = True,
|
use_gamebox: bool = True,
|
||||||
timeout_seconds: float = 12 * 60 * 60,
|
timeout_seconds: float = 12 * 60 * 60,
|
||||||
check: bool = True,
|
check: bool = True,
|
||||||
@@ -291,11 +305,15 @@ 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")
|
||||||
|
native_executable, windows_executable, game_arguments = _game_start_command(
|
||||||
|
paths,
|
||||||
|
start_option,
|
||||||
|
)
|
||||||
if selection.kind == "umu":
|
if selection.kind == "umu":
|
||||||
game_command = [
|
game_command = [
|
||||||
selection.executable,
|
selection.executable,
|
||||||
str(paths.runtime_view / "Game.exe"),
|
native_executable,
|
||||||
"PTEU_REBORN",
|
*game_arguments,
|
||||||
]
|
]
|
||||||
environment = _umu_environment(paths)
|
environment = _umu_environment(paths)
|
||||||
elif selection.kind == "proton7-container":
|
elif selection.kind == "proton7-container":
|
||||||
@@ -303,8 +321,8 @@ def run_game(
|
|||||||
raise RuntimeErrorPT("Proton script path is missing")
|
raise RuntimeErrorPT("Proton script path is missing")
|
||||||
game_command = [
|
game_command = [
|
||||||
selection.executable,
|
selection.executable,
|
||||||
str(paths.runtime_view / "Game.exe"),
|
native_executable,
|
||||||
"PTEU_REBORN",
|
*game_arguments,
|
||||||
]
|
]
|
||||||
environment = _umu_proton7_environment(
|
environment = _umu_proton7_environment(
|
||||||
paths,
|
paths,
|
||||||
@@ -320,8 +338,8 @@ def run_game(
|
|||||||
"ptlinux.proton_compat",
|
"ptlinux.proton_compat",
|
||||||
selection.proton_path,
|
selection.proton_path,
|
||||||
"runinprefix",
|
"runinprefix",
|
||||||
WINDOWS_GAME_EXE,
|
windows_executable,
|
||||||
"PTEU_REBORN",
|
*game_arguments,
|
||||||
]
|
]
|
||||||
environment = _proton_environment(
|
environment = _proton_environment(
|
||||||
paths,
|
paths,
|
||||||
@@ -346,6 +364,57 @@ def run_game(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _game_start_command(
|
||||||
|
paths: AppPaths,
|
||||||
|
option: DepotStartOption,
|
||||||
|
) -> tuple[str, str, tuple[str, ...]]:
|
||||||
|
relative = PurePosixPath(option.path)
|
||||||
|
if (
|
||||||
|
relative.is_absolute()
|
||||||
|
or not relative.parts
|
||||||
|
or any(part in {"", ".", ".."} for part in relative.parts)
|
||||||
|
or relative.suffix.casefold() != ".exe"
|
||||||
|
):
|
||||||
|
raise RuntimeErrorPT(f"invalid realm executable path: {option.path!r}")
|
||||||
|
try:
|
||||||
|
arguments = tuple(shlex.split(option.arguments))
|
||||||
|
except ValueError as exc:
|
||||||
|
raise RuntimeErrorPT(
|
||||||
|
f"invalid launch arguments for realm {option.name}: {exc}"
|
||||||
|
) from exc
|
||||||
|
if not arguments:
|
||||||
|
raise RuntimeErrorPT(f"realm {option.name} has no launch arguments")
|
||||||
|
native = str(_resolve_runtime_path_case(paths.runtime_view, relative))
|
||||||
|
windows_relative = "\\".join(relative.parts)
|
||||||
|
return native, rf"{WINDOWS_GAME_DIRECTORY}\{windows_relative}", arguments
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_runtime_path_case(root: Path, relative: PurePosixPath) -> Path:
|
||||||
|
"""Match Windows manifest casing against the case-sensitive runtime view."""
|
||||||
|
|
||||||
|
direct = root.joinpath(*relative.parts)
|
||||||
|
if direct.exists():
|
||||||
|
return direct
|
||||||
|
cursor = root
|
||||||
|
for part in relative.parts:
|
||||||
|
if not cursor.is_dir():
|
||||||
|
return direct
|
||||||
|
try:
|
||||||
|
matches = tuple(
|
||||||
|
entry for entry in cursor.iterdir() if entry.name.casefold() == part.casefold()
|
||||||
|
)
|
||||||
|
except OSError:
|
||||||
|
return direct
|
||||||
|
if not matches:
|
||||||
|
return direct
|
||||||
|
if len(matches) > 1:
|
||||||
|
raise RuntimeErrorPT(
|
||||||
|
f"realm executable path has ambiguous casing: {relative.as_posix()}"
|
||||||
|
)
|
||||||
|
cursor = matches[0]
|
||||||
|
return cursor
|
||||||
|
|
||||||
|
|
||||||
def run_proton_prefix_command(
|
def run_proton_prefix_command(
|
||||||
paths: AppPaths,
|
paths: AppPaths,
|
||||||
selection: RuntimeSelection,
|
selection: RuntimeSelection,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ SETTINGS_SCHEMA = 1
|
|||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class Settings:
|
class Settings:
|
||||||
|
realm: str = "Babel"
|
||||||
width: int = 1600
|
width: int = 1600
|
||||||
height: int = 900
|
height: int = 900
|
||||||
output_width: int = 1600
|
output_width: int = 1600
|
||||||
@@ -133,6 +134,7 @@ def _read_settings_object(path: Path) -> dict[str, object] | None:
|
|||||||
|
|
||||||
def _settings_from_object(value: dict[str, object]) -> Settings:
|
def _settings_from_object(value: dict[str, object]) -> Settings:
|
||||||
settings = Settings(
|
settings = Settings(
|
||||||
|
realm=_string(value, "realm", "Babel").strip(),
|
||||||
width=_integer(value, "width", 1600),
|
width=_integer(value, "width", 1600),
|
||||||
height=_integer(value, "height", 900),
|
height=_integer(value, "height", 900),
|
||||||
output_width=_integer(value, "output_width", 1600),
|
output_width=_integer(value, "output_width", 1600),
|
||||||
@@ -215,6 +217,12 @@ def _fsync_directory(path: Path) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _validate(settings: Settings) -> None:
|
def _validate(settings: Settings) -> None:
|
||||||
|
if (
|
||||||
|
len(settings.realm) > 64
|
||||||
|
or not settings.realm.strip()
|
||||||
|
or any(ord(character) < 32 for character in settings.realm)
|
||||||
|
):
|
||||||
|
raise ValueError("realm must be a non-empty display name")
|
||||||
for name, value in (
|
for name, value in (
|
||||||
("width", settings.width),
|
("width", settings.width),
|
||||||
("height", settings.height),
|
("height", settings.height),
|
||||||
|
|||||||
1638
ptlinux/ui.py
1638
ptlinux/ui.py
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "PTEU-Linux-Launcher"
|
name = "PTEU-Linux-Launcher"
|
||||||
version = "0.6.5"
|
version = "0.7.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"
|
||||||
dependencies = []
|
dependencies = []
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"archive_sha256": "f3bccddf72816522eb992b7c1216bbb270b91d7ddceb2a41257b2b719cbcb7dd",
|
"archive_sha256": "a74403e6db36f88ee5fe8eef731b49b976eab5842428dd8dac82c6361f52ec44",
|
||||||
"archive_size": 224548,
|
"archive_size": 241062,
|
||||||
"archive_url": "https://git.m4ko.net/m4ko/pristontale-eu-linux/releases/download/v0.6.5/pristontale-eu-linux-update-v0.6.5.tar.gz",
|
"archive_url": "https://git.m4ko.net/m4ko/pristontale-eu-linux/releases/download/v0.7.0/pristontale-eu-linux-update-v0.7.0.tar.gz",
|
||||||
"channel": "stable",
|
"channel": "stable",
|
||||||
"minimum_updater_version": "0.5.0",
|
"minimum_updater_version": "0.5.0",
|
||||||
"notes_url": "https://git.m4ko.net/m4ko/pristontale-eu-linux/releases/tag/v0.6.5",
|
"notes_url": "https://git.m4ko.net/m4ko/pristontale-eu-linux/releases/tag/v0.7.0",
|
||||||
"published_at": "2026-08-30T09:27:45Z",
|
"published_at": "2026-08-30T20:49:27Z",
|
||||||
"schema": 1,
|
"schema": 1,
|
||||||
"version": "0.6.5"
|
"version": "0.7.0"
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
Reference in New Issue
Block a user