8 Commits

15 changed files with 2104 additions and 174 deletions

View File

@@ -1,6 +1,6 @@
# 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
included in this archive.
@@ -15,11 +15,11 @@ Then run:
```text
openssl pkey -pubin -in launcher-update-public.pem -outform DER | sha256sum
openssl dgst -sha256 -verify launcher-update-public.pem \
-signature pristontale-eu-linux-installer-v0-6-5.tar.gz.sig \
pristontale-eu-linux-installer-v0-6-5.tar.gz
sha256sum -c pristontale-eu-linux-installer-v0-6-5.tar.gz.sha256
tar -xzf pristontale-eu-linux-installer-v0-6-5.tar.gz
cd pristontale-eu-linux-installer-v0-6-5
-signature pristontale-eu-linux-installer-v0-7-0.tar.gz.sig \
pristontale-eu-linux-installer-v0-7-0.tar.gz
sha256sum -c pristontale-eu-linux-installer-v0-7-0.tar.gz.sha256
tar -xzf pristontale-eu-linux-installer-v0-7-0.tar.gz
cd pristontale-eu-linux-installer-v0-7-0
./install.sh
```

View File

@@ -8,3 +8,4 @@ Terminal=false
Categories=Game;RolePlaying;
Keywords=PristonTale;MMORPG;Wine;Proton;
StartupNotify=true
StartupWMClass=PristonTaleEU

View File

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

View File

@@ -4,4 +4,4 @@ from .paths import AppPaths
__all__ = ["AppPaths", "__version__"]
__version__ = "0.6.5"
__version__ = "0.7.0"

View File

@@ -10,7 +10,7 @@ import json
from pathlib import Path
import sys
import time
from typing import Sequence
from typing import Callable, Sequence
from ptdiag.logs import (
LogFileState,
@@ -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
@@ -33,12 +38,13 @@ from .launcher import (
launch_official_updater,
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_trace import DEFAULT_TRACE_TIMEOUT_SECONDS, LOGIN_TRACE_DISABLED_REASON
from .overlay import cache_compatibility_binaries, prepare_runtime_view
from .paths import AppPaths
from .runtime import (
game_runtime_ready,
prepare_game_runtime,
run_game,
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.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",
@@ -296,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(
@@ -687,6 +698,7 @@ def _maybe_import_game(
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):
raise LauncherError(
"the imported game has not completed its full publisher verification; "
@@ -706,8 +718,10 @@ def _play(
dry_run: bool,
skip_runtime_setup: bool,
as_json: bool,
*,
realm: str | None = None,
) -> int:
with _operation_guard(paths, "play", dry_run):
with _play_guard(paths, dry_run) as play_lease:
return _play_locked(
paths,
game_dir,
@@ -715,6 +729,9 @@ 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,
)
@@ -725,6 +742,10 @@ def _play_locked(
dry_run: bool,
skip_runtime_setup: bool,
as_json: bool,
*,
realm: str | None = None,
reuse_prepared: bool = False,
before_game: Callable[[], None] | None = None,
) -> int:
if game_dir is None and game_import_requires_verification(paths):
raise LauncherError(
@@ -732,18 +753,55 @@ 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(as_json, 1, 4, "Preparing the managed game view…")
if not dry_run:
_print_operation_status(
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)
_print_operation_status(as_json, 2, 4, "Selecting the configured game runtime…")
selection = select_runtime(settings)
runner = _runner(paths, "play", dry_run)
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…")
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…")
native_log_directory = paths.game_save / "logs"
native_log_baseline = snapshot_native_logs(
@@ -751,12 +809,15 @@ def _play_locked(
patterns=("log_*.txt",),
)
game_started_ns = time.time_ns()
if before_game is not None:
before_game()
try:
outcome = run_game(
paths,
settings,
selection,
runner,
start_option=start_option,
use_gamebox=not no_box,
)
except KeyboardInterrupt:
@@ -784,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),
@@ -1000,10 +1062,19 @@ def _load_or_default_settings(paths: AppPaths, *, persist: bool) -> Settings:
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)
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(
paths: AppPaths,
operation: str,

View File

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

View File

@@ -10,8 +10,13 @@ import sys
import time
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_READY_DELAY_SECONDS = 0.5
_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:
def __init__(self) -> None:
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.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:
"""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))
@@ -265,6 +304,12 @@ class _X11:
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 = [
ctypes.c_void_p,
ctypes.c_ulong,
@@ -325,6 +370,18 @@ def _prepare_game_window(
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(
command: Sequence[str],
window_title: str = WINDOW_TITLE,
@@ -371,6 +428,7 @@ def run_game_window(
game_window = window
game_title = title
game_window_created_at = now
_apply_game_window_identity(mapper, window)
print(
f"Game window created; preparing it: {title}",
file=sys.stderr,

View File

@@ -7,13 +7,51 @@ from datetime import datetime
import fcntl
import os
from pathlib import Path
from typing import Iterator
from typing import Iterator, TextIO
class OperationBusyError(RuntimeError):
"""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
def launcher_operation_lock(lock_path: Path, operation: str) -> Iterator[None]:
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(
f"launcher files are busy ({owner}); wait for it to finish and try again"
) from exc
handle.seek(0)
handle.truncate()
handle.write(
f"{operation}, pid={os.getpid()}, "
f"started={datetime.now().astimezone().isoformat()}\n"
)
handle.flush()
_write_owner(handle, operation)
try:
yield
finally:
@@ -40,3 +72,67 @@ def launcher_operation_lock(lock_path: Path, operation: str) -> Iterator[None]:
handle.truncate()
handle.flush()
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()

View File

@@ -156,10 +156,22 @@ 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"
@property
def patches_cache(self) -> Path:
"""Cached official patch changelog shown beside the news feed."""
return self.cache / "patches.json"
@property
def broadcast_cache(self) -> Path:
"""Cached latest announcement from the broadcast service."""

View File

@@ -7,19 +7,22 @@ from datetime import datetime
import hashlib
import json
import os
from pathlib import Path
from pathlib import Path, PurePosixPath
import platform
import shlex
import shutil
import sys
from typing import Mapping, Sequence
from .command import CommandOutcome, CommandRunner, OutputLineFilter
from .depot import DEFAULT_DEPOT_START_OPTION, DepotStartOption
from .gamebox import build_gamebox_command
from .paths import AppPaths
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")
DXVK_D3D9_OVERRIDE = "native,builtin"
DLL_OVERRIDES_KEY = r"HKCU\Software\Wine\DllOverrides"
@@ -277,12 +280,23 @@ def prepare_game_runtime(
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(
paths: AppPaths,
settings: Settings,
selection: RuntimeSelection,
runner: CommandRunner,
*,
start_option: DepotStartOption = DEFAULT_DEPOT_START_OPTION,
use_gamebox: bool = True,
timeout_seconds: float = 12 * 60 * 60,
check: bool = True,
@@ -291,11 +305,15 @@ 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")
native_executable, windows_executable, game_arguments = _game_start_command(
paths,
start_option,
)
if selection.kind == "umu":
game_command = [
selection.executable,
str(paths.runtime_view / "Game.exe"),
"PTEU_REBORN",
native_executable,
*game_arguments,
]
environment = _umu_environment(paths)
elif selection.kind == "proton7-container":
@@ -303,8 +321,8 @@ def run_game(
raise RuntimeErrorPT("Proton script path is missing")
game_command = [
selection.executable,
str(paths.runtime_view / "Game.exe"),
"PTEU_REBORN",
native_executable,
*game_arguments,
]
environment = _umu_proton7_environment(
paths,
@@ -320,8 +338,8 @@ def run_game(
"ptlinux.proton_compat",
selection.proton_path,
"runinprefix",
WINDOWS_GAME_EXE,
"PTEU_REBORN",
windows_executable,
*game_arguments,
]
environment = _proton_environment(
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(
paths: AppPaths,
selection: RuntimeSelection,

View File

@@ -19,6 +19,7 @@ SETTINGS_SCHEMA = 1
@dataclass(frozen=True)
class Settings:
realm: str = "Babel"
width: int = 1600
height: int = 900
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:
settings = Settings(
realm=_string(value, "realm", "Babel").strip(),
width=_integer(value, "width", 1600),
height=_integer(value, "height", 900),
output_width=_integer(value, "output_width", 1600),
@@ -215,6 +217,12 @@ def _fsync_directory(path: Path) -> 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 (
("width", settings.width),
("height", settings.height),

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,11 +1,11 @@
{
"archive_sha256": "c1132f2e2d74579ea29fa59d69cb1df881800445be11d17e0fff7bc5d34c12fa",
"archive_size": 226431,
"archive_url": "https://git.m4ko.net/m4ko/pristontale-eu-linux/releases/download/v0.6.0/pristontale-eu-linux-update-v0.6.0.tar.gz",
"archive_sha256": "a74403e6db36f88ee5fe8eef731b49b976eab5842428dd8dac82c6361f52ec44",
"archive_size": 241062,
"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",
"minimum_updater_version": "0.5.0",
"notes_url": "https://git.m4ko.net/m4ko/pristontale-eu-linux/releases/tag/v0.6.0",
"published_at": "2026-08-29T21:16:21Z",
"notes_url": "https://git.m4ko.net/m4ko/pristontale-eu-linux/releases/tag/v0.7.0",
"published_at": "2026-08-30T20:49:27Z",
"schema": 1,
"version": "0.6.0"
"version": "0.7.0"
}

Binary file not shown.