6 Commits

12 changed files with 926 additions and 66 deletions

View File

@@ -1,6 +1,6 @@
# PristonTale EU for Linux
Version 0.8.1 installs, updates, repairs, and launches PristonTale EU through a
Version 0.8.3 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-8-1.tar.gz.sig \
pristontale-eu-linux-installer-v0-8-1.tar.gz
sha256sum -c pristontale-eu-linux-installer-v0-8-1.tar.gz.sha256
tar -xzf pristontale-eu-linux-installer-v0-8-1.tar.gz
cd pristontale-eu-linux-installer-v0-8-1
-signature pristontale-eu-linux-installer-v0-8-3.tar.gz.sig \
pristontale-eu-linux-installer-v0-8-3.tar.gz
sha256sum -c pristontale-eu-linux-installer-v0-8-3.tar.gz.sha256
tar -xzf pristontale-eu-linux-installer-v0-8-3.tar.gz
cd pristontale-eu-linux-installer-v0-8-3
./install.sh
```

View File

@@ -12,17 +12,20 @@ export PTLINUX_APP_HOME="${app_home}"
release_root=""
for candidate in "${app_home}/current" "${app_home}/previous" "${app_home}"
do
if [ ! -d "${candidate}/ptlinux" ]
# Installed releases already pass an isolated import self-check before their
# activation link is changed. Keep only a cheap corruption/fallback check on
# every boot instead of starting Python twice and importing the full CLI twice.
if [ ! -r "${candidate}/ptlinux/__init__.py" ] || \
[ ! -r "${candidate}/ptlinux/__main__.py" ] || \
[ ! -r "${candidate}/ptlinux/cli.py" ] || \
[ ! -r "${candidate}/ptdiag/__init__.py" ] || \
[ ! -r "${candidate}/ptdiag/assbin.py" ] || \
[ ! -r "${candidate}/ptdiag/logs.py" ]
then
continue
fi
if "$python_bin" -I -B -c \
'import sys; sys.path.insert(0, sys.argv[1]); import ptlinux, ptlinux.cli' \
"${candidate}" >/dev/null 2>&1
then
release_root="${candidate}"
break
fi
release_root="${candidate}"
break
done
if [ -z "${release_root}" ]

View File

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

View File

@@ -1,4 +1,28 @@
from .cli import main
from __future__ import annotations
import sys
def main() -> int:
"""Dispatch the common GUI path without importing every CLI operation."""
arguments = sys.argv[1:]
if not arguments or arguments == ["gui"]:
from .paths import AppPaths
from .ui import run_dashboard
try:
run_dashboard(AppPaths.from_environment())
except KeyboardInterrupt:
return 130
except (ValueError, OSError, RuntimeError) as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
return 0
from .cli import main as cli_main
return cli_main(arguments)
raise SystemExit(main())

View File

@@ -1,4 +1,4 @@
"""Native X11 command construction for the clean borderless game window."""
"""Native X11 command construction for the managed game window."""
from __future__ import annotations

View File

@@ -19,6 +19,20 @@ from .desktop_identity import (
WINDOW_TITLE = APPLICATION_NAME
WINDOW_DISCOVERY_TIMEOUT_SECONDS = 60.0
WINDOW_READY_DELAY_SECONDS = 0.5
WINDOW_BORDERLESS_SETTLE_SECONDS = 0.1
WINDOW_SURFACE_REFRESH_DELAY_SECONDS = 0.15
WINDOW_FOCUS_DELAY_SECONDS = 0.2
WINDOW_STARTUP_GUARD_SECONDS = 12.0
WINDOW_STARTUP_POLL_SECONDS = 0.1
WINDOW_ACTIVATION_DELAY_SECONDS = 0.2
WINDOW_STARTUP_RECOVERY_LIMIT = 3
_X_REVERT_TO_PARENT = 2
_X_CURRENT_TIME = 0
_X_CLIENT_MESSAGE = 33
_X_SUBSTRUCTURE_NOTIFY_MASK = 1 << 19
_X_SUBSTRUCTURE_REDIRECT_MASK = 1 << 20
_X_WM_STATE_ICONIC = 3
_MOTIF_HINTS_DECORATIONS = 1 << 1
_X_ERROR_HANDLER = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_void_p, ctypes.c_void_p)
@@ -78,6 +92,35 @@ class _XClassHint(ctypes.Structure):
]
class _XClientMessageData(ctypes.Union):
_fields_ = [
("bytes", ctypes.c_char * 20),
("shorts", ctypes.c_short * 10),
("longs", ctypes.c_long * 5),
]
class _XClientMessageEvent(ctypes.Structure):
_fields_ = [
("type", ctypes.c_int),
("serial", ctypes.c_ulong),
("send_event", ctypes.c_int),
("display", ctypes.c_void_p),
("window", ctypes.c_ulong),
("message_type", ctypes.c_ulong),
("format", ctypes.c_int),
("data", _XClientMessageData),
]
class _XEvent(ctypes.Union):
_fields_ = [
("type", ctypes.c_int),
("client_message", _XClientMessageEvent),
("padding", ctypes.c_long * 24),
]
class _X11:
def __init__(self) -> None:
library_path = ctypes.util.find_library("X11") or "libX11.so.6"
@@ -94,6 +137,7 @@ class _X11:
if not self.display:
self._restore_error_handler()
raise GameboxRuntimeError("could not open the host X11 display")
self._atoms: dict[bytes, int] = {}
def close(self) -> None:
try:
@@ -117,17 +161,24 @@ class _X11:
title = self._window_title(window)
if title is not None:
windows.append((window, title))
return _select_game_window(windows, title_fragment, excluded_window_ids)
selected = _select_game_window(windows, title_fragment, excluded_window_ids)
if selected is None:
return None
window, title = selected
return self._client_window(window), title
def _root_windows(self) -> list[int]:
root = self.library.XDefaultRootWindow(self.display)
return self._child_windows(root)
def _child_windows(self, window: int) -> list[int]:
root_return = ctypes.c_ulong()
parent_return = ctypes.c_ulong()
children = ctypes.POINTER(ctypes.c_ulong)()
child_count = ctypes.c_uint()
status = self.library.XQueryTree(
self.display,
root,
window,
ctypes.byref(root_return),
ctypes.byref(parent_return),
ctypes.byref(children),
@@ -141,11 +192,267 @@ class _X11:
if children:
self.library.XFree(ctypes.cast(children, ctypes.c_void_p))
def map_window(self, window: int, *, borderless: bool) -> None:
def _client_window(self, window: int) -> int:
"""Resolve a window-manager frame to the ICCCM client it contains."""
queue = [window]
visited: set[int] = set()
while queue and len(visited) < 64:
candidate = queue.pop(0)
if candidate in visited:
continue
visited.add(candidate)
if self._has_wm_state(candidate):
return candidate
queue.extend(self._child_windows(candidate))
return window
def _intern_atom(self, name: bytes, *, only_if_exists: bool) -> int:
"""Return one atom ID without repeating X-server lookups."""
try:
atoms = self._atoms
except AttributeError:
# Keep helpers usable in isolated tests that construct the mapper without
# opening a real X11 display.
atoms = {}
self._atoms = atoms
cached_atom = atoms.get(name)
if cached_atom is not None:
return cached_atom
atom = int(
self.library.XInternAtom(
self.display,
name,
int(only_if_exists),
)
)
# A missing only-if-exists lookup must remain retryable: a later call may
# create the atom or another client may publish it.
if atom != 0:
atoms[name] = atom
return atom
def _has_wm_state(self, window: int) -> bool:
wm_state = self._intern_atom(b"WM_STATE", only_if_exists=True)
if wm_state == 0:
return False
actual_type = ctypes.c_ulong()
actual_format = ctypes.c_int()
item_count = ctypes.c_ulong()
bytes_after = ctypes.c_ulong()
value = ctypes.POINTER(ctypes.c_ubyte)()
status = self.library.XGetWindowProperty(
self.display,
window,
wm_state,
0,
0,
0,
0,
ctypes.byref(actual_type),
ctypes.byref(actual_format),
ctypes.byref(item_count),
ctypes.byref(bytes_after),
ctypes.byref(value),
)
try:
return status == 0 and actual_type.value != 0
finally:
if value:
self.library.XFree(ctypes.cast(value, ctypes.c_void_p))
def window_state(self, window: int) -> int | None:
"""Return the ICCCM WM_STATE value for a managed client."""
values = self._long_property(window, b"WM_STATE", 2)
if not values:
return None
return values[0]
def window_is_borderless(self, window: int) -> bool | None:
"""Return whether the client currently requests no Motif decorations."""
hints = self._long_property(window, b"_MOTIF_WM_HINTS", 5)
if hints is None or len(hints) < 3:
return None
if hints[0] & _MOTIF_HINTS_DECORATIONS == 0:
return False
return hints[2] == 0
def _long_property(
self,
window: int,
name: bytes,
maximum_items: int,
) -> tuple[int, ...] | None:
atom = self._intern_atom(name, only_if_exists=True)
if atom == 0:
return None
actual_type = ctypes.c_ulong()
actual_format = ctypes.c_int()
item_count = ctypes.c_ulong()
bytes_after = ctypes.c_ulong()
value = ctypes.POINTER(ctypes.c_ubyte)()
status = self.library.XGetWindowProperty(
self.display,
window,
atom,
0,
maximum_items,
0,
0,
ctypes.byref(actual_type),
ctypes.byref(actual_format),
ctypes.byref(item_count),
ctypes.byref(bytes_after),
ctypes.byref(value),
)
try:
if (
status != 0
or actual_type.value == 0
or actual_format.value != 32
or not value
):
return None
longs = ctypes.cast(value, ctypes.POINTER(ctypes.c_ulong))
return tuple(
int(longs[index] & 0xFFFFFFFF)
for index in range(min(item_count.value, maximum_items))
)
finally:
if value:
self.library.XFree(ctypes.cast(value, ctypes.c_void_p))
def window_size(self, window: int) -> tuple[int, int] | None:
"""Return the current X11 client dimensions."""
root = ctypes.c_ulong()
x = ctypes.c_int()
y = ctypes.c_int()
width = ctypes.c_uint()
height = ctypes.c_uint()
border_width = ctypes.c_uint()
depth = ctypes.c_uint()
if self.library.XGetGeometry(
self.display,
window,
ctypes.byref(root),
ctypes.byref(x),
ctypes.byref(y),
ctypes.byref(width),
ctypes.byref(height),
ctypes.byref(border_width),
ctypes.byref(depth),
) == 0:
return None
return width.value, height.value
def map_window(
self,
window: int,
*,
borderless: bool,
) -> tuple[int, int] | None:
target_size = self.window_size(window) if borderless else None
if borderless:
self._remove_decorations(window)
self.library.XMapRaised(self.display, window)
self.library.XFlush(self.display)
return target_size
def refresh_render_surface(
self,
window: int,
*,
target_size: tuple[int, int] | None = None,
) -> bool:
"""Nudge the mapped client size once after borderless styling.
Some Wine/DXVK combinations retain the host desktop-sized render surface
after the top-level game window becomes borderless. A one-pixel resize followed
by an exact restore makes Wine reconcile the surface with the real window.
Never iconify or unmap a Wine client for this refresh. Wine can process the
resulting state change after startup and minimize the game at its login screen.
"""
if target_size is None:
target_size = self.window_size(window)
if target_size is None:
return False
original_width, original_height = target_size
if original_width > 1:
nudged_width = original_width - 1
nudged_height = original_height
elif original_height > 1:
nudged_width = original_width
nudged_height = original_height - 1
else:
return False
self.library.XResizeWindow(
self.display,
window,
nudged_width,
nudged_height,
)
self.library.XSync(self.display, 0)
try:
time.sleep(WINDOW_SURFACE_REFRESH_DELAY_SECONDS)
finally:
# Always restore the exact starting dimensions if the short wait is
# interrupted. The game remains mapped for the entire operation.
self.library.XResizeWindow(
self.display,
window,
original_width,
original_height,
)
self.library.XSync(self.display, 0)
return True
def focus_window(self, window: int) -> None:
"""Raise the mapped game client and give it keyboard focus."""
self.library.XMapRaised(self.display, window)
self.library.XSetInputFocus(
self.display,
window,
_X_REVERT_TO_PARENT,
_X_CURRENT_TIME,
)
self.library.XSync(self.display, 0)
def activate_window(self, window: int) -> bool:
"""Ask the window manager to restore and activate one client."""
active_window = self._intern_atom(
b"_NET_ACTIVE_WINDOW",
only_if_exists=False,
)
if active_window == 0:
return False
event = _XEvent()
event.client_message.type = _X_CLIENT_MESSAGE
event.client_message.serial = 0
event.client_message.send_event = 1
event.client_message.display = self.display
event.client_message.window = window
event.client_message.message_type = active_window
event.client_message.format = 32
event.client_message.data.longs[0] = 1
event.client_message.data.longs[1] = _X_CURRENT_TIME
status = self.library.XSendEvent(
self.display,
self.library.XDefaultRootWindow(self.display),
0,
_X_SUBSTRUCTURE_NOTIFY_MASK | _X_SUBSTRUCTURE_REDIRECT_MASK,
ctypes.byref(event),
)
self.library.XSync(self.display, 0)
return status != 0
def set_window_identity(
self,
@@ -176,7 +483,7 @@ class _X11:
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))
ping_atom = self._intern_atom(b"_NET_WM_PING", only_if_exists=False)
if ping_atom == 0:
return False
protocols = self._wm_protocols(window)
@@ -227,7 +534,7 @@ class _X11:
self.library.XFree(ctypes.cast(protocols, ctypes.c_void_p))
def _remove_decorations(self, window: int) -> None:
atom = self.library.XInternAtom(self.display, b"_MOTIF_WM_HINTS", 0)
atom = self._intern_atom(b"_MOTIF_WM_HINTS", only_if_exists=False)
if atom == 0:
return
hints = _MotifWmHints(
@@ -304,6 +611,21 @@ class _X11:
ctypes.c_int,
]
self.library.XChangeProperty.restype = ctypes.c_int
self.library.XGetWindowProperty.argtypes = [
ctypes.c_void_p,
ctypes.c_ulong,
ctypes.c_ulong,
ctypes.c_long,
ctypes.c_long,
ctypes.c_int,
ctypes.c_ulong,
ctypes.POINTER(ctypes.c_ulong),
ctypes.POINTER(ctypes.c_int),
ctypes.POINTER(ctypes.c_ulong),
ctypes.POINTER(ctypes.c_ulong),
ctypes.POINTER(ctypes.POINTER(ctypes.c_ubyte)),
]
self.library.XGetWindowProperty.restype = ctypes.c_int
self.library.XSetClassHint.argtypes = [
ctypes.c_void_p,
ctypes.c_ulong,
@@ -326,6 +648,40 @@ class _X11:
self.library.XSetWMProtocols.restype = ctypes.c_int
self.library.XMapRaised.argtypes = [ctypes.c_void_p, ctypes.c_ulong]
self.library.XMapRaised.restype = ctypes.c_int
self.library.XGetGeometry.argtypes = [
ctypes.c_void_p,
ctypes.c_ulong,
ctypes.POINTER(ctypes.c_ulong),
ctypes.POINTER(ctypes.c_int),
ctypes.POINTER(ctypes.c_int),
ctypes.POINTER(ctypes.c_uint),
ctypes.POINTER(ctypes.c_uint),
ctypes.POINTER(ctypes.c_uint),
ctypes.POINTER(ctypes.c_uint),
]
self.library.XGetGeometry.restype = ctypes.c_int
self.library.XResizeWindow.argtypes = [
ctypes.c_void_p,
ctypes.c_ulong,
ctypes.c_uint,
ctypes.c_uint,
]
self.library.XResizeWindow.restype = ctypes.c_int
self.library.XSetInputFocus.argtypes = [
ctypes.c_void_p,
ctypes.c_ulong,
ctypes.c_int,
ctypes.c_ulong,
]
self.library.XSetInputFocus.restype = ctypes.c_int
self.library.XSendEvent.argtypes = [
ctypes.c_void_p,
ctypes.c_ulong,
ctypes.c_int,
ctypes.c_long,
ctypes.POINTER(_XEvent),
]
self.library.XSendEvent.restype = ctypes.c_int
self.library.XSync.argtypes = [ctypes.c_void_p, ctypes.c_int]
self.library.XSync.restype = ctypes.c_int
self.library.XFlush.argtypes = [ctypes.c_void_p]
@@ -367,7 +723,40 @@ def _prepare_game_window(
file=sys.stderr,
flush=True,
)
mapper.map_window(window, borderless=borderless)
target_size = mapper.map_window(window, borderless=borderless)
if not borderless:
return
if target_size is not None:
time.sleep(WINDOW_BORDERLESS_SETTLE_SECONDS)
try:
if target_size is None:
refreshed = mapper.refresh_render_surface(window)
else:
refreshed = mapper.refresh_render_surface(
window,
target_size=target_size,
)
except Exception as exc:
print(
f"Warning: could not synchronize the borderless game surface ({exc}); "
"continuing normally",
file=sys.stderr,
flush=True,
)
else:
if refreshed:
print(
"Borderless game surface synchronized with its window.",
file=sys.stderr,
flush=True,
)
else:
print(
"Warning: the window manager declined the borderless surface "
"refresh; continuing normally",
file=sys.stderr,
flush=True,
)
def _apply_game_window_identity(mapper: _X11, window: int) -> None:
@@ -382,6 +771,107 @@ def _apply_game_window_identity(mapper: _X11, window: int) -> None:
)
def _focus_game_window(mapper: _X11, window: int) -> None:
"""Let the dashboard process its ready event before focusing the game."""
time.sleep(WINDOW_FOCUS_DELAY_SECONDS)
try:
mapper.focus_window(window)
except Exception as exc:
print(
f"Warning: could not focus the game window ({exc}); continuing normally",
file=sys.stderr,
flush=True,
)
def _guard_game_window_startup(
mapper: _X11,
process: subprocess.Popen[bytes],
window: int,
*,
borderless: bool,
) -> None:
"""Recover Wine's login-screen window transition without hiding the client."""
deadline = time.monotonic() + WINDOW_STARTUP_GUARD_SECONDS
try:
initial_borderless = (
mapper.window_is_borderless(window) if borderless else None
)
# A readable decorated value can already be Wine's fast login reset. The
# initial preparation immediately before this guard requested borderless.
borderless_was_applied = borderless and initial_borderless is not None
except Exception as exc:
print(
f"Warning: could not monitor the game startup window ({exc}); "
"continuing normally",
file=sys.stderr,
flush=True,
)
return
recovery_count = 0
while process.poll() is None and time.monotonic() < deadline:
try:
state = mapper.window_state(window)
borderless_now = (
mapper.window_is_borderless(window) if borderless else None
)
except Exception as exc:
print(
f"Warning: could not inspect the game startup window ({exc}); "
"continuing normally",
file=sys.stderr,
flush=True,
)
return
became_iconic = state == _X_WM_STATE_ICONIC
login_style_reset = borderless_was_applied and borderless_now is False
if not became_iconic and not login_style_reset:
time.sleep(WINDOW_STARTUP_POLL_SECONDS)
continue
recovery_count += 1
reason = "was minimized" if became_iconic else "changed its window layout"
print(
f"Game startup window {reason}; restoring and refocusing it.",
file=sys.stderr,
flush=True,
)
try:
mapper.activate_window(window)
time.sleep(WINDOW_ACTIVATION_DELAY_SECONDS)
_prepare_game_window(
mapper,
window,
borderless=borderless,
suppress_wm_ping=False,
)
mapper.activate_window(window)
mapper.focus_window(window)
if borderless:
borderless_was_applied = (
mapper.window_is_borderless(window) is True
)
except Exception as exc:
print(
f"Warning: could not recover the game startup window ({exc}); "
"continuing normally",
file=sys.stderr,
flush=True,
)
return
if recovery_count >= WINDOW_STARTUP_RECOVERY_LIMIT:
print(
"Warning: the game repeatedly changed its startup window; "
"stopped automatic recovery.",
file=sys.stderr,
flush=True,
)
return
time.sleep(WINDOW_STARTUP_POLL_SECONDS)
def run_game_window(
command: Sequence[str],
window_title: str = WINDOW_TITLE,
@@ -450,6 +940,13 @@ def run_game_window(
file=sys.stderr,
flush=True,
)
_focus_game_window(mapper, game_window)
_guard_game_window_startup(
mapper,
process,
game_window,
borderless=borderless,
)
return int(process.wait())
time.sleep(0.1)
if process.poll() is None:

View File

@@ -44,15 +44,46 @@ PATCHED_V21_GAME_DLL_SHA256 = (
RETIRED_POST_AUTH_GAME_DLL_SHA256 = (
"93cd27284eb294753da1111c99bfc9689f82b6d4a59302aee9fdbd8cb52452dd"
)
RUNTIME_VIEW_CONFIGURATION_VERSION = 1
RUNTIME_VIEW_CONFIGURATION_VERSION = 2
class OverlayError(RuntimeError):
"""Raised when a safe runtime view cannot be built."""
@dataclass(frozen=True)
class RuntimeFileIdentity:
"""Cheap change detector for one previously hash-verified runtime input."""
name: str
device: int
inode: int
mode: int
size: int
mtime_ns: int
ctime_ns: int
@dataclass(frozen=True)
class OverlayResult:
source_game: str
runtime_view: str
source_scene_sha256: str
runtime_scene_sha256: str
scene_action: str
binary_action: str
game_exe_sha256: str
game_dll_sha256: str
input_identities: tuple[RuntimeFileIdentity, ...]
runtime_scene_identity: RuntimeFileIdentity
created_at: str
links_created: int
@dataclass(frozen=True)
class LegacyOverlayResult:
"""Version-one runtime manifest retained only for a one-time fast migration."""
source_game: str
runtime_view: str
source_scene_sha256: str
@@ -94,8 +125,9 @@ def inspect_runtime_compatibility(
This preflight is intentionally read-only: it never normalizes a scene,
builds the verified runtime DLL, prepares save data, or replaces the runtime
view. The normal preparation path still performs every authoritative check
immediately before launch.
view. Preparation reuses a prior authoritative result only while every input
identity and runtime-view link remains unchanged; otherwise it repeats the
full checks before launch.
"""
requested_source = source_game.expanduser()
@@ -105,6 +137,9 @@ def inspect_runtime_compatibility(
try:
source = requested_source.resolve(strict=True)
_validate_game_tree(source)
cached = _cached_runtime_compatibility(paths, source)
if cached is not None:
return cached
source_scene = source / WHISPERING_VALE_RELATIVE
inspection = inspect_wrapped_assbin(source_scene)
source_scene_sha256 = inspection.file_sha256
@@ -139,10 +174,14 @@ def prepare_runtime_view(
source = source_game.expanduser().resolve(strict=True)
_validate_game_tree(source)
_validate_runtime_target(paths)
game_save = _prepare_persistent_save(paths, source)
reused = _reuse_runtime_view(paths, source, game_save)
if reused is not None:
return reused
input_identities = _runtime_input_identities(paths, source)
source_scene = source / WHISPERING_VALE_RELATIVE
inspection = inspect_wrapped_assbin(source_scene)
action = _scene_action(inspection)
game_save = _prepare_persistent_save(paths, source)
(
binary_action,
game_exe_source,
@@ -152,7 +191,7 @@ def prepare_runtime_view(
paths,
source,
)
reused = _reuse_runtime_view(
legacy_reuse = _reuse_legacy_runtime_view(
paths,
source,
inspection,
@@ -162,9 +201,10 @@ def prepare_runtime_view(
game_exe_source,
game_dll_source,
source_binary_hashes,
input_identities,
)
if reused is not None:
return reused
if legacy_reuse is not None:
return legacy_reuse
build = paths.data / f".{paths.runtime_view.name}.build-{uuid.uuid4().hex}"
backup = paths.data / f".{paths.runtime_view.name}.old-{uuid.uuid4().hex}"
@@ -213,6 +253,10 @@ def prepare_runtime_view(
validate_scene_normalization(normalization)
else:
shutil.copy2(source_scene, runtime_scene)
if _runtime_input_identities(paths, source) != input_identities:
raise OverlayError(
"game files changed while the managed runtime view was being prepared"
)
if paths.runtime_view.exists() or paths.runtime_view.is_symlink():
paths.runtime_view.replace(backup)
@@ -234,6 +278,11 @@ def prepare_runtime_view(
binary_action=binary_action,
game_exe_sha256=_sha256(paths.runtime_view / "Game.exe"),
game_dll_sha256=_sha256(paths.runtime_view / "game.dll"),
input_identities=input_identities,
runtime_scene_identity=_runtime_file_identity(
"runtime-scene",
paths.runtime_view / WHISPERING_VALE_RELATIVE,
),
created_at=datetime.now().astimezone().isoformat(),
links_created=links_created,
)
@@ -470,7 +519,99 @@ def _reject_save_symlinks(root: Path) -> None:
raise OverlayError(f"persistent game save contains a symbolic link: {path}")
def _reuse_runtime_view(
def _runtime_file_identity(
name: str,
path: Path,
*,
follow_symlinks: bool = True,
) -> RuntimeFileIdentity:
state = path.stat(follow_symlinks=follow_symlinks)
return RuntimeFileIdentity(
name=name,
device=state.st_dev,
inode=state.st_ino,
mode=state.st_mode,
size=state.st_size,
mtime_ns=state.st_mtime_ns,
ctime_ns=state.st_ctime_ns,
)
def _runtime_input_identities(
paths: AppPaths,
source: Path,
) -> tuple[RuntimeFileIdentity, ...]:
"""Snapshot inputs whose hashes established the cached runtime profile.
Nanosecond mtime plus ctime/inode/device prevents ordinary edits, replacements,
or timestamp restoration from bypassing the next authoritative hash check.
"""
identities = [
_runtime_file_identity("source-game-exe", source / "Game.exe"),
_runtime_file_identity("source-game-dll", source / "game.dll"),
_runtime_file_identity("source-scene", source / WHISPERING_VALE_RELATIVE),
]
cache = paths.compatibility_binaries
if cache.exists() or cache.is_symlink():
identities.append(
_runtime_file_identity(
"compatibility-cache",
cache,
follow_symlinks=False,
)
)
if cache.is_dir():
for name in COMPATIBILITY_BINARY_HASHES:
path = cache / name
if path.exists() or path.is_symlink():
identities.append(
_runtime_file_identity(
f"compatibility-cache-{name.casefold()}",
path,
)
)
return tuple(identities)
def _matching_cached_runtime_result(
paths: AppPaths,
source: Path,
) -> OverlayResult | None:
result = _read_overlay_result(paths.runtime_manifest)
if result is None:
return None
if result.source_game != str(source):
return None
if result.runtime_view != str(paths.runtime_view):
return None
try:
current_identities = _runtime_input_identities(paths, source)
except OSError:
return None
if current_identities != result.input_identities:
return None
return result
def _cached_runtime_compatibility(
paths: AppPaths,
source: Path,
) -> RuntimeCompatibility | None:
result = _matching_cached_runtime_result(paths, source)
if result is None:
return None
return RuntimeCompatibility(
compatible=True,
source_game=str(source),
source_scene_sha256=result.source_scene_sha256,
scene_action=result.scene_action,
binary_action=result.binary_action,
reason=None,
)
def _reuse_legacy_runtime_view(
paths: AppPaths,
source: Path,
inspection: SceneInspection,
@@ -480,22 +621,82 @@ def _reuse_runtime_view(
game_exe_source: Path,
game_dll_source: Path,
source_binary_hashes: Mapping[str, str],
input_identities: tuple[RuntimeFileIdentity, ...],
) -> OverlayResult | None:
result = _read_overlay_result(paths.runtime_manifest)
"""Upgrade an unchanged v1 view after its original hash checks pass once."""
legacy = _read_legacy_overlay_result(paths.runtime_manifest)
if legacy is None:
return None
if legacy.source_game != str(source):
return None
if legacy.runtime_view != str(paths.runtime_view):
return None
if legacy.source_scene_sha256 != inspection.file_sha256:
return None
if legacy.scene_action != scene_action or legacy.binary_action != binary_action:
return None
if legacy.game_exe_sha256 != source_binary_hashes.get("Game.exe"):
return None
if legacy.game_dll_sha256 != source_binary_hashes.get("game.dll"):
return None
if not _runtime_layout_ready(
paths,
source,
game_save,
game_exe_source,
game_dll_source,
legacy.links_created,
):
return None
runtime_scene = paths.runtime_view / WHISPERING_VALE_RELATIVE
try:
if _sha256(runtime_scene) != legacy.runtime_scene_sha256:
return None
if _runtime_input_identities(paths, source) != input_identities:
raise OverlayError(
"game files changed while the legacy runtime view was being verified"
)
runtime_scene_identity = _runtime_file_identity(
"runtime-scene",
runtime_scene,
)
except OSError:
return None
result = OverlayResult(
source_game=legacy.source_game,
runtime_view=legacy.runtime_view,
source_scene_sha256=legacy.source_scene_sha256,
runtime_scene_sha256=legacy.runtime_scene_sha256,
scene_action=legacy.scene_action,
binary_action=legacy.binary_action,
game_exe_sha256=legacy.game_exe_sha256,
game_dll_sha256=legacy.game_dll_sha256,
input_identities=input_identities,
runtime_scene_identity=runtime_scene_identity,
created_at=legacy.created_at,
links_created=legacy.links_created,
)
_write_json(
paths.runtime_manifest,
{
"configuration_version": RUNTIME_VIEW_CONFIGURATION_VERSION,
**asdict(result),
},
)
return result
def _reuse_runtime_view(
paths: AppPaths,
source: Path,
game_save: Path,
) -> OverlayResult | None:
result = _matching_cached_runtime_result(paths, source)
if result is None:
return None
if result.source_game != str(source):
return None
if result.runtime_view != str(paths.runtime_view):
return None
if result.source_scene_sha256 != inspection.file_sha256:
return None
if result.scene_action != scene_action or result.binary_action != binary_action:
return None
if result.game_exe_sha256 != source_binary_hashes.get("Game.exe"):
return None
if result.game_dll_sha256 != source_binary_hashes.get("game.dll"):
return None
game_exe_source = source / "Game.exe"
game_dll_source = source / "game.dll"
if not _runtime_layout_ready(
paths,
source,
@@ -507,7 +708,12 @@ def _reuse_runtime_view(
return None
runtime_scene = paths.runtime_view / WHISPERING_VALE_RELATIVE
try:
if _sha256(runtime_scene) != result.runtime_scene_sha256:
if (
_runtime_file_identity("runtime-scene", runtime_scene)
!= result.runtime_scene_identity
):
return None
if _runtime_input_identities(paths, source) != result.input_identities:
return None
except OSError:
return None
@@ -651,7 +857,101 @@ def _read_overlay_result(path: Path) -> OverlayResult | None:
return None
if links_created < 0:
return None
input_values = value.get("input_identities")
if not isinstance(input_values, list) or not input_values:
return None
input_identities: list[RuntimeFileIdentity] = []
for input_value in input_values:
identity = _parse_runtime_file_identity(input_value)
if identity is None:
return None
input_identities.append(identity)
input_names = {identity.name for identity in input_identities}
if len(input_names) != len(input_identities) or not {
"source-game-exe",
"source-game-dll",
"source-scene",
}.issubset(input_names):
return None
runtime_scene_identity = _parse_runtime_file_identity(
value.get("runtime_scene_identity")
)
if (
runtime_scene_identity is None
or runtime_scene_identity.name != "runtime-scene"
):
return None
return OverlayResult(
source_game=value["source_game"],
runtime_view=value["runtime_view"],
source_scene_sha256=value["source_scene_sha256"],
runtime_scene_sha256=value["runtime_scene_sha256"],
scene_action=value["scene_action"],
binary_action=value["binary_action"],
game_exe_sha256=value["game_exe_sha256"],
game_dll_sha256=value["game_dll_sha256"],
input_identities=tuple(input_identities),
runtime_scene_identity=runtime_scene_identity,
created_at=value["created_at"],
links_created=links_created,
)
def _parse_runtime_file_identity(value: object) -> RuntimeFileIdentity | None:
if not isinstance(value, dict):
return None
name = value.get("name")
if not isinstance(name, str) or not name:
return None
fields: dict[str, int] = {}
for field in ("device", "inode", "mode", "size", "mtime_ns", "ctime_ns"):
field_value = value.get(field)
if (
isinstance(field_value, bool)
or not isinstance(field_value, int)
or field_value < 0
):
return None
fields[field] = field_value
return RuntimeFileIdentity(
name=name,
device=fields["device"],
inode=fields["inode"],
mode=fields["mode"],
size=fields["size"],
mtime_ns=fields["mtime_ns"],
ctime_ns=fields["ctime_ns"],
)
def _read_legacy_overlay_result(path: Path) -> LegacyOverlayResult | None:
try:
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError):
return None
if not isinstance(value, dict) or value.get("configuration_version") != 1:
return None
string_fields = (
"source_game",
"runtime_view",
"source_scene_sha256",
"runtime_scene_sha256",
"scene_action",
"binary_action",
"game_exe_sha256",
"game_dll_sha256",
"created_at",
)
if not all(isinstance(value.get(name), str) for name in string_fields):
return None
links_created = value.get("links_created")
if (
isinstance(links_created, bool)
or not isinstance(links_created, int)
or links_created < 0
):
return None
return LegacyOverlayResult(
source_game=value["source_game"],
runtime_view=value["runtime_view"],
source_scene_sha256=value["source_scene_sha256"],

View File

@@ -15,6 +15,7 @@ from .paths import AppPaths
SETTINGS_SCHEMA = 1
_TITLE_FRAME_DEFAULT_MIGRATION_KEY = "title_frame_default_migrated"
@dataclass(frozen=True)
@@ -26,7 +27,7 @@ class Settings:
output_height: int = 900
refresh_rate: int = 60
fullscreen: bool = False
borderless: bool = True
borderless: bool = False
suppress_unresponsive_warnings: bool = True
scaler: str = "fit"
filter: str = "linear"
@@ -141,7 +142,7 @@ def _settings_from_object(value: dict[str, object]) -> Settings:
output_height=_integer(value, "output_height", 900),
refresh_rate=_integer(value, "refresh_rate", 60),
fullscreen=_boolean(value, "fullscreen", False),
borderless=_boolean(value, "borderless", True),
borderless=_migrated_borderless(value),
suppress_unresponsive_warnings=_boolean(
value,
"suppress_unresponsive_warnings",
@@ -158,6 +159,22 @@ def _settings_from_object(value: dict[str, object]) -> Settings:
return settings
def _migrated_borderless(value: dict[str, object]) -> bool:
"""Retire the old implicit borderless default exactly once.
Older releases wrote ``borderless=true`` for every default installation, so
they cannot distinguish an inherited value from an explicit choice. The
migration marker keeps schema 1 backward compatible while letting users opt
back into Borderless after the decorated default has been applied.
"""
borderless = _boolean(value, "borderless", False)
migrated = _boolean(value, _TITLE_FRAME_DEFAULT_MIGRATION_KEY, False)
if not migrated:
return False
return borderless
def _decode_settings(content: bytes) -> str:
"""Accept native UTF-8 plus settings saved by common Windows editors."""
@@ -174,6 +191,7 @@ def _settings_need_migration(value: dict[str, object]) -> bool:
return (
value.get("schema") != SETTINGS_SCHEMA
or not _SETTINGS_FIELD_NAMES.issubset(value)
or value.get(_TITLE_FRAME_DEFAULT_MIGRATION_KEY) is not True
)
@@ -185,6 +203,7 @@ def _write_settings(
payload = dict(existing or {})
payload.update(asdict(settings))
payload["schema"] = SETTINGS_SCHEMA
payload[_TITLE_FRAME_DEFAULT_MIGRATION_KEY] = True
temporary = paths.settings_file.with_name(
f".{paths.settings_file.name}.{os.getpid()}.{threading.get_ident()}."
f"{secrets.token_hex(8)}.tmp"

View File

@@ -105,7 +105,7 @@ ACTIVITY_LOG_TRIM_LINES = 500
TRANSFER_RATE_WINDOW_SECONDS = 3.0
TRANSFER_RATE_MINIMUM_INTERVAL_SECONDS = 0.2
TRANSFER_RATE_IDLE_SECONDS = 2.0
DASHBOARD_PRELOADER_MINIMUM_MILLISECONDS = 650
DASHBOARD_PRELOADER_MINIMUM_MILLISECONDS = 300
DASHBOARD_PRELOADER_TIMEOUT_MILLISECONDS = 10_000
DASHBOARD_TRANSITION_STEP_MILLISECONDS = 16
DASHBOARD_TRANSITION_STEPS = 9
@@ -120,6 +120,7 @@ SERVER_STATUS_STALE_SECONDS = 5 * 60
DASHBOARD_STARTUP_COMPONENTS = frozenset(
{
"broadcast",
"cached_content",
"compatibility",
"launcher_update",
"logo",
@@ -130,6 +131,7 @@ DASHBOARD_STARTUP_COMPONENTS = frozenset(
"version",
}
)
DASHBOARD_STARTUP_REQUIRED_COMPONENTS = frozenset({"cached_content"})
@dataclass(frozen=True)
@@ -559,9 +561,9 @@ def dashboard_preloader_delay(
def dashboard_content_ready(ready_components: set[str] | frozenset[str]) -> bool:
"""Return whether every user-visible startup component has settled."""
"""Return whether cached content is ready for an interactive dashboard."""
return DASHBOARD_STARTUP_COMPONENTS.issubset(ready_components)
return DASHBOARD_STARTUP_REQUIRED_COMPONENTS.issubset(ready_components)
def dashboard_transition_opacity(step: int, *, fading_in: bool) -> float:
@@ -1284,6 +1286,7 @@ def run_dashboard(paths: AppPaths) -> None:
startup_timeout_after: str | None = None
startup_progress_after: str | None = None
startup_progress_target = 0.0
remote_refresh_started = False
root.deiconify()
root.lift()
startup_overlay.lift()
@@ -1375,6 +1378,7 @@ def run_dashboard(paths: AppPaths) -> None:
set_root_opacity(1.0)
root.lift()
root.focus_set()
root.after_idle(start_remote_content_refresh)
def fade_dashboard_in(step: int = 0) -> None:
if not set_root_opacity(
@@ -3389,6 +3393,13 @@ def run_dashboard(paths: AppPaths) -> None:
threading.Thread(target=version_worker, name="ptlinux-version", daemon=True).start()
refresh_launcher_update_check()
def start_remote_content_refresh() -> None:
nonlocal remote_refresh_started
if remote_refresh_started:
return
remote_refresh_started = True
refresh_remote_content()
def release_game_launch_controls(
client_id: int,
*,
@@ -3411,12 +3422,14 @@ def run_dashboard(paths: AppPaths) -> None:
speed_text.set("")
progress_bar.stop()
close_game_preloader()
root.deiconify()
if not ready:
root.deiconify()
set_busy(False)
refresh_primary_action(update_activity=False)
if ready:
status.set("Ready for another account.")
progress_text.set("Click Play to open another game window.")
dashboard_chrome.minimize()
elif cancelled:
root.lift()
status.set("Game launch cancelled.")
@@ -4093,8 +4106,8 @@ def run_dashboard(paths: AppPaths) -> None:
ttk.Label(
display_card,
text=(
"The launcher uses the proven client resolution and can remove the "
"new game window's host decorations."
"The launcher keeps the game window's title bar by default. Enable "
"Borderless only when you want to remove its host decorations."
),
style="SettingsHint.TLabel",
wraplength=360,
@@ -4103,7 +4116,7 @@ def run_dashboard(paths: AppPaths) -> None:
presentation.grid(row=1, column=0, columnspan=2, sticky="w")
ttk.Checkbutton(
presentation,
text="Borderless",
text="Remove title bar and borders (Borderless)",
variable=borderless_value,
style="Settings.TCheckbutton",
).pack(anchor="w")
@@ -4280,15 +4293,19 @@ def run_dashboard(paths: AppPaths) -> None:
if cached_launcher_update is not None:
show_launcher_update_check(cached_launcher_update, log_reason=False)
refresh_runtime_compatibility()
mark_startup_component_ready(
"cached_content",
"Cached dashboard content ready",
)
poll_events()
load_official_logo()
update_startup_progress("Loading online dashboard content…")
startup_overlay.update_idletasks()
startup_timeout_after = root.after(
DASHBOARD_PRELOADER_TIMEOUT_MILLISECONDS,
timeout_startup_preloader,
)
refresh_remote_content()
if not startup_transition_started:
startup_timeout_after = root.after(
DASHBOARD_PRELOADER_TIMEOUT_MILLISECONDS,
timeout_startup_preloader,
)
maybe_start_dashboard_transition()
try:
root.mainloop()

View File

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

View File

@@ -1,11 +1,11 @@
{
"archive_sha256": "fac790b8bdca1689999de0562c360f299df6a78f542f86ada86b997b0091eafb",
"archive_size": 254243,
"archive_url": "https://git.m4ko.net/m4ko/pristontale-eu-linux/releases/download/v0.8.0/pristontale-eu-linux-update-v0.8.0.tar.gz",
"archive_sha256": "dfaddcbda762eaf34fd80f9592d6173176cfbd41189a96fae023f5459fd6492d",
"archive_size": 260645,
"archive_url": "https://git.m4ko.net/m4ko/pristontale-eu-linux/releases/download/v0.8.3/pristontale-eu-linux-update-v0.8.3.tar.gz",
"channel": "stable",
"minimum_updater_version": "0.5.0",
"notes_url": "https://git.m4ko.net/m4ko/pristontale-eu-linux/releases/tag/v0.8.0",
"published_at": "2026-08-31T21:27:27Z",
"notes_url": "https://git.m4ko.net/m4ko/pristontale-eu-linux/releases/tag/v0.8.3",
"published_at": "2026-09-01T20:24:03Z",
"schema": 1,
"version": "0.8.0"
"version": "0.8.3"
}

Binary file not shown.