Fixed issues with windowed mode and fullscreen.
This commit is contained in:
@@ -19,8 +19,12 @@ from .desktop_identity import (
|
|||||||
WINDOW_TITLE = APPLICATION_NAME
|
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
|
||||||
WINDOW_BORDERLESS_SETTLE_SECONDS = 0.1
|
WINDOW_SURFACE_SETTLE_SECONDS = 0.1
|
||||||
WINDOW_SURFACE_REFRESH_DELAY_SECONDS = 0.15
|
WINDOW_SURFACE_REFRESH_DELAY_SECONDS = 0.15
|
||||||
|
WINDOW_NUDGE_CONFIRM_TIMEOUT_SECONDS = 0.15
|
||||||
|
WINDOW_STATE_CONFIRM_TIMEOUT_SECONDS = 0.75
|
||||||
|
WINDOW_GEOMETRY_CONFIRM_TIMEOUT_SECONDS = 0.5
|
||||||
|
WINDOW_CONFIRM_POLL_SECONDS = 0.025
|
||||||
WINDOW_FOCUS_DELAY_SECONDS = 0.2
|
WINDOW_FOCUS_DELAY_SECONDS = 0.2
|
||||||
WINDOW_STARTUP_GUARD_SECONDS = 12.0
|
WINDOW_STARTUP_GUARD_SECONDS = 12.0
|
||||||
WINDOW_STARTUP_POLL_SECONDS = 0.1
|
WINDOW_STARTUP_POLL_SECONDS = 0.1
|
||||||
@@ -32,6 +36,8 @@ _X_CLIENT_MESSAGE = 33
|
|||||||
_X_SUBSTRUCTURE_NOTIFY_MASK = 1 << 19
|
_X_SUBSTRUCTURE_NOTIFY_MASK = 1 << 19
|
||||||
_X_SUBSTRUCTURE_REDIRECT_MASK = 1 << 20
|
_X_SUBSTRUCTURE_REDIRECT_MASK = 1 << 20
|
||||||
_X_WM_STATE_ICONIC = 3
|
_X_WM_STATE_ICONIC = 3
|
||||||
|
_NET_WM_STATE_REMOVE = 0
|
||||||
|
_NET_WM_STATE_SOURCE_APPLICATION = 1
|
||||||
_MOTIF_HINTS_DECORATIONS = 1 << 1
|
_MOTIF_HINTS_DECORATIONS = 1 << 1
|
||||||
_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)
|
||||||
|
|
||||||
@@ -280,6 +286,88 @@ class _X11:
|
|||||||
return False
|
return False
|
||||||
return hints[2] == 0
|
return hints[2] == 0
|
||||||
|
|
||||||
|
def window_is_fullscreen_or_maximized(self, window: int) -> bool:
|
||||||
|
"""Return whether EWMH currently marks the client as screen-filling."""
|
||||||
|
|
||||||
|
states = self._long_property(window, b"_NET_WM_STATE", 64)
|
||||||
|
if not states:
|
||||||
|
return False
|
||||||
|
return any(
|
||||||
|
atom != 0 and atom in states
|
||||||
|
for atom in self._screen_filling_state_atoms()
|
||||||
|
)
|
||||||
|
|
||||||
|
def force_windowed_state(self, window: int) -> bool:
|
||||||
|
"""Remove fullscreen/maximized EWMH state and wait for WM acknowledgement.
|
||||||
|
|
||||||
|
The state property must not be edited directly on a mapped client. EWMH
|
||||||
|
requires a client message to the root window so KWin/XWayland can restore
|
||||||
|
the window's previous geometry before the render surface is refreshed.
|
||||||
|
|
||||||
|
Return ``True`` only when a screen-filling state was present and its removal
|
||||||
|
was confirmed. Return ``False`` when the window was already normal.
|
||||||
|
"""
|
||||||
|
|
||||||
|
states = set(self._long_property(window, b"_NET_WM_STATE", 64) or ())
|
||||||
|
fullscreen, maximized_vertical, maximized_horizontal = (
|
||||||
|
self._screen_filling_state_atoms()
|
||||||
|
)
|
||||||
|
maximized = tuple(
|
||||||
|
atom
|
||||||
|
for atom in (maximized_vertical, maximized_horizontal)
|
||||||
|
if atom != 0 and atom in states
|
||||||
|
)
|
||||||
|
fullscreen_is_set = fullscreen != 0 and fullscreen in states
|
||||||
|
if not maximized and not fullscreen_is_set:
|
||||||
|
return False
|
||||||
|
|
||||||
|
state_atom = self._intern_atom(b"_NET_WM_STATE", only_if_exists=True)
|
||||||
|
if state_atom == 0:
|
||||||
|
raise GameboxRuntimeError("the window manager has no EWMH state atom")
|
||||||
|
requests: list[tuple[int, int]] = []
|
||||||
|
if maximized:
|
||||||
|
requests.append(
|
||||||
|
(maximized[0], maximized[1] if len(maximized) > 1 else 0)
|
||||||
|
)
|
||||||
|
if fullscreen_is_set:
|
||||||
|
requests.append((fullscreen, 0))
|
||||||
|
for first_state, second_state in requests:
|
||||||
|
if not self._send_root_client_message(
|
||||||
|
window,
|
||||||
|
state_atom,
|
||||||
|
(
|
||||||
|
_NET_WM_STATE_REMOVE,
|
||||||
|
first_state,
|
||||||
|
second_state,
|
||||||
|
_NET_WM_STATE_SOURCE_APPLICATION,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
raise GameboxRuntimeError(
|
||||||
|
"the window manager rejected the windowed-state request"
|
||||||
|
)
|
||||||
|
if not self._wait_for_windowed_state(window):
|
||||||
|
raise GameboxRuntimeError(
|
||||||
|
"the window manager did not confirm normal windowed state"
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _screen_filling_state_atoms(self) -> tuple[int, int, int]:
|
||||||
|
return (
|
||||||
|
self._intern_atom(b"_NET_WM_STATE_FULLSCREEN", only_if_exists=True),
|
||||||
|
self._intern_atom(b"_NET_WM_STATE_MAXIMIZED_VERT", only_if_exists=True),
|
||||||
|
self._intern_atom(b"_NET_WM_STATE_MAXIMIZED_HORZ", only_if_exists=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _wait_for_windowed_state(self, window: int) -> bool:
|
||||||
|
deadline = time.monotonic() + WINDOW_STATE_CONFIRM_TIMEOUT_SECONDS
|
||||||
|
while self.window_is_fullscreen_or_maximized(window):
|
||||||
|
remaining = deadline - time.monotonic()
|
||||||
|
if remaining <= 0:
|
||||||
|
return False
|
||||||
|
time.sleep(min(WINDOW_CONFIRM_POLL_SECONDS, remaining))
|
||||||
|
return True
|
||||||
|
|
||||||
def _long_property(
|
def _long_property(
|
||||||
self,
|
self,
|
||||||
window: int,
|
window: int,
|
||||||
@@ -349,13 +437,49 @@ class _X11:
|
|||||||
return None
|
return None
|
||||||
return width.value, height.value
|
return width.value, height.value
|
||||||
|
|
||||||
|
def _wait_for_window_size(
|
||||||
|
self,
|
||||||
|
window: int,
|
||||||
|
expected_size: tuple[int, int],
|
||||||
|
*,
|
||||||
|
timeout_seconds: float = WINDOW_GEOMETRY_CONFIRM_TIMEOUT_SECONDS,
|
||||||
|
) -> bool:
|
||||||
|
"""Wait briefly for an asynchronous WM ConfigureRequest decision."""
|
||||||
|
|
||||||
|
deadline = time.monotonic() + timeout_seconds
|
||||||
|
while self.window_size(window) != expected_size:
|
||||||
|
remaining = deadline - time.monotonic()
|
||||||
|
if remaining <= 0:
|
||||||
|
return False
|
||||||
|
time.sleep(min(WINDOW_CONFIRM_POLL_SECONDS, remaining))
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _resize_window_and_wait(
|
||||||
|
self,
|
||||||
|
window: int,
|
||||||
|
target_size: tuple[int, int],
|
||||||
|
*,
|
||||||
|
timeout_seconds: float = WINDOW_GEOMETRY_CONFIRM_TIMEOUT_SECONDS,
|
||||||
|
) -> bool:
|
||||||
|
width, height = target_size
|
||||||
|
self.library.XResizeWindow(self.display, window, width, height)
|
||||||
|
self.library.XSync(self.display, 0)
|
||||||
|
return self._wait_for_window_size(
|
||||||
|
window,
|
||||||
|
target_size,
|
||||||
|
timeout_seconds=timeout_seconds,
|
||||||
|
)
|
||||||
|
|
||||||
def map_window(
|
def map_window(
|
||||||
self,
|
self,
|
||||||
window: int,
|
window: int,
|
||||||
*,
|
*,
|
||||||
borderless: bool,
|
borderless: bool,
|
||||||
) -> tuple[int, int] | None:
|
) -> tuple[int, int] | None:
|
||||||
target_size = self.window_size(window) if borderless else None
|
# Capture the real client size for both decorated and borderless windows.
|
||||||
|
# Wine/DXVK can otherwise retain a desktop-sized render surface inside a
|
||||||
|
# smaller decorated client on some window managers.
|
||||||
|
target_size = self.window_size(window)
|
||||||
if borderless:
|
if borderless:
|
||||||
self._remove_decorations(window)
|
self._remove_decorations(window)
|
||||||
self.library.XMapRaised(self.display, window)
|
self.library.XMapRaised(self.display, window)
|
||||||
@@ -368,11 +492,11 @@ class _X11:
|
|||||||
*,
|
*,
|
||||||
target_size: tuple[int, int] | None = None,
|
target_size: tuple[int, int] | None = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Nudge the mapped client size once after borderless styling.
|
"""Nudge the mapped client size once after Wine creates the window.
|
||||||
|
|
||||||
Some Wine/DXVK combinations retain the host desktop-sized render surface
|
Some Wine/DXVK combinations retain the host desktop-sized render surface
|
||||||
after the top-level game window becomes borderless. A one-pixel resize followed
|
inside a smaller top-level game window. A one-pixel resize followed by an
|
||||||
by an exact restore makes Wine reconcile the surface with the real window.
|
exact restore makes Wine reconcile the surface with the real client size.
|
||||||
|
|
||||||
Never iconify or unmap a Wine client for this refresh. Wine can process the
|
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.
|
resulting state change after startup and minimize the game at its login screen.
|
||||||
@@ -392,26 +516,22 @@ class _X11:
|
|||||||
else:
|
else:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
self.library.XResizeWindow(
|
|
||||||
self.display,
|
|
||||||
window,
|
|
||||||
nudged_width,
|
|
||||||
nudged_height,
|
|
||||||
)
|
|
||||||
self.library.XSync(self.display, 0)
|
|
||||||
try:
|
try:
|
||||||
time.sleep(WINDOW_SURFACE_REFRESH_DELAY_SECONDS)
|
nudge_confirmed = self._resize_window_and_wait(
|
||||||
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,
|
window,
|
||||||
original_width,
|
(nudged_width, nudged_height),
|
||||||
original_height,
|
timeout_seconds=WINDOW_NUDGE_CONFIRM_TIMEOUT_SECONDS,
|
||||||
)
|
)
|
||||||
self.library.XSync(self.display, 0)
|
if nudge_confirmed:
|
||||||
return True
|
time.sleep(WINDOW_SURFACE_REFRESH_DELAY_SECONDS)
|
||||||
|
finally:
|
||||||
|
# Always request and verify the exact starting dimensions, including when
|
||||||
|
# KWin declined the nudge or the short wait was interrupted.
|
||||||
|
restore_confirmed = self._resize_window_and_wait(
|
||||||
|
window,
|
||||||
|
(original_width, original_height),
|
||||||
|
)
|
||||||
|
return nudge_confirmed and restore_confirmed
|
||||||
|
|
||||||
def focus_window(self, window: int) -> None:
|
def focus_window(self, window: int) -> None:
|
||||||
"""Raise the mapped game client and give it keyboard focus."""
|
"""Raise the mapped game client and give it keyboard focus."""
|
||||||
@@ -434,16 +554,32 @@ class _X11:
|
|||||||
)
|
)
|
||||||
if active_window == 0:
|
if active_window == 0:
|
||||||
return False
|
return False
|
||||||
|
return self._send_root_client_message(
|
||||||
|
window,
|
||||||
|
active_window,
|
||||||
|
(1, _X_CURRENT_TIME, 0, 0, 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _send_root_client_message(
|
||||||
|
self,
|
||||||
|
window: int,
|
||||||
|
message_type: int,
|
||||||
|
data: Sequence[int],
|
||||||
|
) -> bool:
|
||||||
|
"""Send one 32-bit EWMH client message to the root window."""
|
||||||
|
|
||||||
|
if len(data) != 5:
|
||||||
|
raise ValueError("an X11 client message requires exactly five values")
|
||||||
event = _XEvent()
|
event = _XEvent()
|
||||||
event.client_message.type = _X_CLIENT_MESSAGE
|
event.client_message.type = _X_CLIENT_MESSAGE
|
||||||
event.client_message.serial = 0
|
event.client_message.serial = 0
|
||||||
event.client_message.send_event = 1
|
event.client_message.send_event = 1
|
||||||
event.client_message.display = self.display
|
event.client_message.display = self.display
|
||||||
event.client_message.window = window
|
event.client_message.window = window
|
||||||
event.client_message.message_type = active_window
|
event.client_message.message_type = message_type
|
||||||
event.client_message.format = 32
|
event.client_message.format = 32
|
||||||
event.client_message.data.longs[0] = 1
|
for index, value in enumerate(data):
|
||||||
event.client_message.data.longs[1] = _X_CURRENT_TIME
|
event.client_message.data.longs[index] = value
|
||||||
status = self.library.XSendEvent(
|
status = self.library.XSendEvent(
|
||||||
self.display,
|
self.display,
|
||||||
self.library.XDefaultRootWindow(self.display),
|
self.library.XDefaultRootWindow(self.display),
|
||||||
@@ -724,10 +860,38 @@ def _prepare_game_window(
|
|||||||
flush=True,
|
flush=True,
|
||||||
)
|
)
|
||||||
target_size = mapper.map_window(window, borderless=borderless)
|
target_size = mapper.map_window(window, borderless=borderless)
|
||||||
if not borderless:
|
forced_windowed = False
|
||||||
return
|
try:
|
||||||
if target_size is not None:
|
forced_windowed = mapper.force_windowed_state(window)
|
||||||
time.sleep(WINDOW_BORDERLESS_SETTLE_SECONDS)
|
except Exception as exc:
|
||||||
|
print(
|
||||||
|
f"Warning: could not force the game into normal windowed mode ({exc}); "
|
||||||
|
"continuing with surface synchronization",
|
||||||
|
file=sys.stderr,
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
if forced_windowed:
|
||||||
|
print(
|
||||||
|
"Game window forced out of fullscreen/maximized mode.",
|
||||||
|
file=sys.stderr,
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
if target_size is not None or forced_windowed:
|
||||||
|
time.sleep(WINDOW_SURFACE_SETTLE_SECONDS)
|
||||||
|
if forced_windowed:
|
||||||
|
# Let KWin finish restoring the pre-fullscreen client geometry before it
|
||||||
|
# becomes the authoritative surface size.
|
||||||
|
try:
|
||||||
|
target_size = mapper.window_size(window)
|
||||||
|
except Exception as exc:
|
||||||
|
print(
|
||||||
|
f"Warning: could not read the restored game-window size ({exc}); "
|
||||||
|
"retrying during surface synchronization",
|
||||||
|
file=sys.stderr,
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
target_size = None
|
||||||
try:
|
try:
|
||||||
if target_size is None:
|
if target_size is None:
|
||||||
refreshed = mapper.refresh_render_surface(window)
|
refreshed = mapper.refresh_render_surface(window)
|
||||||
@@ -738,7 +902,7 @@ def _prepare_game_window(
|
|||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print(
|
print(
|
||||||
f"Warning: could not synchronize the borderless game surface ({exc}); "
|
f"Warning: could not synchronize the game surface ({exc}); "
|
||||||
"continuing normally",
|
"continuing normally",
|
||||||
file=sys.stderr,
|
file=sys.stderr,
|
||||||
flush=True,
|
flush=True,
|
||||||
@@ -746,13 +910,13 @@ def _prepare_game_window(
|
|||||||
else:
|
else:
|
||||||
if refreshed:
|
if refreshed:
|
||||||
print(
|
print(
|
||||||
"Borderless game surface synchronized with its window.",
|
"Game render surface synchronized with its window.",
|
||||||
file=sys.stderr,
|
file=sys.stderr,
|
||||||
flush=True,
|
flush=True,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
print(
|
print(
|
||||||
"Warning: the window manager declined the borderless surface "
|
"Warning: the window manager declined the game-surface "
|
||||||
"refresh; continuing normally",
|
"refresh; continuing normally",
|
||||||
file=sys.stderr,
|
file=sys.stderr,
|
||||||
flush=True,
|
flush=True,
|
||||||
@@ -814,6 +978,7 @@ def _guard_game_window_startup(
|
|||||||
while process.poll() is None and time.monotonic() < deadline:
|
while process.poll() is None and time.monotonic() < deadline:
|
||||||
try:
|
try:
|
||||||
state = mapper.window_state(window)
|
state = mapper.window_state(window)
|
||||||
|
screen_filling = mapper.window_is_fullscreen_or_maximized(window)
|
||||||
borderless_now = (
|
borderless_now = (
|
||||||
mapper.window_is_borderless(window) if borderless else None
|
mapper.window_is_borderless(window) if borderless else None
|
||||||
)
|
)
|
||||||
@@ -826,13 +991,23 @@ def _guard_game_window_startup(
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
became_iconic = state == _X_WM_STATE_ICONIC
|
became_iconic = state == _X_WM_STATE_ICONIC
|
||||||
|
entered_screen_filling_mode = screen_filling is True
|
||||||
login_style_reset = borderless_was_applied and borderless_now is False
|
login_style_reset = borderless_was_applied and borderless_now is False
|
||||||
if not became_iconic and not login_style_reset:
|
if (
|
||||||
|
not became_iconic
|
||||||
|
and not entered_screen_filling_mode
|
||||||
|
and not login_style_reset
|
||||||
|
):
|
||||||
time.sleep(WINDOW_STARTUP_POLL_SECONDS)
|
time.sleep(WINDOW_STARTUP_POLL_SECONDS)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
recovery_count += 1
|
recovery_count += 1
|
||||||
reason = "was minimized" if became_iconic else "changed its window layout"
|
if became_iconic:
|
||||||
|
reason = "was minimized"
|
||||||
|
elif entered_screen_filling_mode:
|
||||||
|
reason = "entered fullscreen/maximized mode"
|
||||||
|
else:
|
||||||
|
reason = "changed its window layout"
|
||||||
print(
|
print(
|
||||||
f"Game startup window {reason}; restoring and refocusing it.",
|
f"Game startup window {reason}; restoring and refocusing it.",
|
||||||
file=sys.stderr,
|
file=sys.stderr,
|
||||||
|
|||||||
@@ -45,6 +45,10 @@ RETIRED_POST_AUTH_GAME_DLL_SHA256 = (
|
|||||||
"93cd27284eb294753da1111c99bfc9689f82b6d4a59302aee9fdbd8cb52452dd"
|
"93cd27284eb294753da1111c99bfc9689f82b6d4a59302aee9fdbd8cb52452dd"
|
||||||
)
|
)
|
||||||
RUNTIME_VIEW_CONFIGURATION_VERSION = 2
|
RUNTIME_VIEW_CONFIGURATION_VERSION = 2
|
||||||
|
WINDOWED_GAME_CONFIG_VALUES = (
|
||||||
|
(b"windowed", b"Windowed", b"1"),
|
||||||
|
(b"startmaximized", b"StartMaximized", b"False"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class OverlayError(RuntimeError):
|
class OverlayError(RuntimeError):
|
||||||
@@ -175,7 +179,8 @@ def prepare_runtime_view(
|
|||||||
_validate_game_tree(source)
|
_validate_game_tree(source)
|
||||||
_validate_runtime_target(paths)
|
_validate_runtime_target(paths)
|
||||||
game_save = _prepare_persistent_save(paths, source)
|
game_save = _prepare_persistent_save(paths, source)
|
||||||
reused = _reuse_runtime_view(paths, source, game_save)
|
game_config = _prepare_persistent_game_config(paths, source)
|
||||||
|
reused = _reuse_runtime_view(paths, source, game_save, game_config)
|
||||||
if reused is not None:
|
if reused is not None:
|
||||||
return reused
|
return reused
|
||||||
input_identities = _runtime_input_identities(paths, source)
|
input_identities = _runtime_input_identities(paths, source)
|
||||||
@@ -197,6 +202,7 @@ def prepare_runtime_view(
|
|||||||
inspection,
|
inspection,
|
||||||
action,
|
action,
|
||||||
game_save,
|
game_save,
|
||||||
|
game_config,
|
||||||
binary_action,
|
binary_action,
|
||||||
game_exe_source,
|
game_exe_source,
|
||||||
game_dll_source,
|
game_dll_source,
|
||||||
@@ -211,7 +217,7 @@ def prepare_runtime_view(
|
|||||||
links_created = 0
|
links_created = 0
|
||||||
try:
|
try:
|
||||||
build.mkdir(parents=True, exist_ok=False)
|
build.mkdir(parents=True, exist_ok=False)
|
||||||
top_level_exceptions = {"game", "save"}
|
top_level_exceptions = {"game", "game.ini", "save"}
|
||||||
for name, binary_source in (
|
for name, binary_source in (
|
||||||
("Game.exe", game_exe_source),
|
("Game.exe", game_exe_source),
|
||||||
("game.dll", game_dll_source),
|
("game.dll", game_dll_source),
|
||||||
@@ -225,6 +231,8 @@ def prepare_runtime_view(
|
|||||||
)
|
)
|
||||||
(build / "save").symlink_to(game_save, target_is_directory=True)
|
(build / "save").symlink_to(game_save, target_is_directory=True)
|
||||||
links_created += 1
|
links_created += 1
|
||||||
|
(build / "game.ini").symlink_to(game_config)
|
||||||
|
links_created += 1
|
||||||
if game_exe_source != source / "Game.exe":
|
if game_exe_source != source / "Game.exe":
|
||||||
(build / "Game.exe").symlink_to(game_exe_source)
|
(build / "Game.exe").symlink_to(game_exe_source)
|
||||||
links_created += 1
|
links_created += 1
|
||||||
@@ -510,6 +518,108 @@ def _prepare_persistent_save(paths: AppPaths, source: Path) -> Path:
|
|||||||
return target
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
def _prepare_persistent_game_config(paths: AppPaths, source: Path) -> Path:
|
||||||
|
"""Keep game settings writable while forcing a true windowed startup."""
|
||||||
|
|
||||||
|
target = paths.game_config
|
||||||
|
if target.is_symlink():
|
||||||
|
raise OverlayError(f"persistent game config must not be a symbolic link: {target}")
|
||||||
|
if target.exists() and not target.is_file():
|
||||||
|
raise OverlayError(f"persistent game config is not a file: {target}")
|
||||||
|
source_config = target if target.is_file() else source / "game.ini"
|
||||||
|
try:
|
||||||
|
content = source_config.read_bytes()
|
||||||
|
mode = source_config.stat().st_mode & 0o777
|
||||||
|
except OSError as exc:
|
||||||
|
raise OverlayError(f"could not read game config {source_config}: {exc}") from exc
|
||||||
|
windowed_content = _force_windowed_game_config(content)
|
||||||
|
if source_config == target and windowed_content == content:
|
||||||
|
return target
|
||||||
|
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
temporary = target.with_name(f".{target.name}.{uuid.uuid4().hex}.tmp")
|
||||||
|
try:
|
||||||
|
with temporary.open("xb") as handle:
|
||||||
|
handle.write(windowed_content)
|
||||||
|
handle.flush()
|
||||||
|
os.fsync(handle.fileno())
|
||||||
|
temporary.chmod(mode)
|
||||||
|
temporary.replace(target)
|
||||||
|
except BaseException:
|
||||||
|
temporary.unlink(missing_ok=True)
|
||||||
|
raise
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
def _force_windowed_game_config(content: bytes) -> bytes:
|
||||||
|
"""Set only the two Screen keys that can trigger a desktop-sized backbuffer."""
|
||||||
|
|
||||||
|
if b"\x00" in content:
|
||||||
|
raise OverlayError("game.ini uses an unsupported encoding")
|
||||||
|
newline = b"\r\n" if b"\r\n" in content else b"\n"
|
||||||
|
desired = {
|
||||||
|
key: (display_name, value)
|
||||||
|
for key, display_name, value in WINDOWED_GAME_CONFIG_VALUES
|
||||||
|
}
|
||||||
|
result: list[bytes] = []
|
||||||
|
in_screen = False
|
||||||
|
found_screen = False
|
||||||
|
found_settings: set[bytes] = set()
|
||||||
|
|
||||||
|
def append_missing_settings() -> None:
|
||||||
|
if result and not result[-1].endswith((b"\r", b"\n")):
|
||||||
|
result[-1] += newline
|
||||||
|
for key, display_name, value in WINDOWED_GAME_CONFIG_VALUES:
|
||||||
|
if key in found_settings:
|
||||||
|
continue
|
||||||
|
result.append(display_name + b"=" + value + newline)
|
||||||
|
found_settings.add(key)
|
||||||
|
|
||||||
|
for line in content.splitlines(keepends=True):
|
||||||
|
if line.endswith(b"\r\n"):
|
||||||
|
body, line_ending = line[:-2], b"\r\n"
|
||||||
|
elif line.endswith((b"\r", b"\n")):
|
||||||
|
body, line_ending = line[:-1], line[-1:]
|
||||||
|
else:
|
||||||
|
body, line_ending = line, b""
|
||||||
|
stripped = body.strip()
|
||||||
|
if stripped.startswith(b"[") and b"]" in stripped:
|
||||||
|
if in_screen:
|
||||||
|
append_missing_settings()
|
||||||
|
section_end = stripped.find(b"]")
|
||||||
|
in_screen = stripped[1:section_end].strip().lower() == b"screen"
|
||||||
|
found_screen = found_screen or in_screen
|
||||||
|
if in_screen and b"=" in body and not stripped.startswith((b";", b"#")):
|
||||||
|
equals = body.find(b"=")
|
||||||
|
key = body[:equals].strip().lower()
|
||||||
|
setting = desired.get(key)
|
||||||
|
if setting is not None:
|
||||||
|
leading_value_space = body[equals + 1 :]
|
||||||
|
leading_value_space = leading_value_space[
|
||||||
|
: len(leading_value_space) - len(leading_value_space.lstrip(b" \t"))
|
||||||
|
]
|
||||||
|
result.append(
|
||||||
|
body[: equals + 1]
|
||||||
|
+ leading_value_space
|
||||||
|
+ setting[1]
|
||||||
|
+ line_ending
|
||||||
|
)
|
||||||
|
found_settings.add(key)
|
||||||
|
continue
|
||||||
|
result.append(line)
|
||||||
|
|
||||||
|
if in_screen:
|
||||||
|
append_missing_settings()
|
||||||
|
elif not found_screen:
|
||||||
|
if result and not result[-1].endswith((b"\r", b"\n")):
|
||||||
|
result[-1] += newline
|
||||||
|
if result and result[-1].strip():
|
||||||
|
result.append(newline)
|
||||||
|
result.append(b"[Screen]" + newline)
|
||||||
|
append_missing_settings()
|
||||||
|
return b"".join(result)
|
||||||
|
|
||||||
|
|
||||||
def _reject_save_symlinks(root: Path) -> None:
|
def _reject_save_symlinks(root: Path) -> None:
|
||||||
for directory, directory_names, file_names in os.walk(root, followlinks=False):
|
for directory, directory_names, file_names in os.walk(root, followlinks=False):
|
||||||
parent = Path(directory)
|
parent = Path(directory)
|
||||||
@@ -617,6 +727,7 @@ def _reuse_legacy_runtime_view(
|
|||||||
inspection: SceneInspection,
|
inspection: SceneInspection,
|
||||||
scene_action: str,
|
scene_action: str,
|
||||||
game_save: Path,
|
game_save: Path,
|
||||||
|
game_config: Path,
|
||||||
binary_action: str,
|
binary_action: str,
|
||||||
game_exe_source: Path,
|
game_exe_source: Path,
|
||||||
game_dll_source: Path,
|
game_dll_source: Path,
|
||||||
@@ -640,10 +751,16 @@ def _reuse_legacy_runtime_view(
|
|||||||
return None
|
return None
|
||||||
if legacy.game_dll_sha256 != source_binary_hashes.get("game.dll"):
|
if legacy.game_dll_sha256 != source_binary_hashes.get("game.dll"):
|
||||||
return None
|
return None
|
||||||
|
_migrate_runtime_game_config_link(
|
||||||
|
paths.runtime_view / "game.ini",
|
||||||
|
source / "game.ini",
|
||||||
|
game_config,
|
||||||
|
)
|
||||||
if not _runtime_layout_ready(
|
if not _runtime_layout_ready(
|
||||||
paths,
|
paths,
|
||||||
source,
|
source,
|
||||||
game_save,
|
game_save,
|
||||||
|
game_config,
|
||||||
game_exe_source,
|
game_exe_source,
|
||||||
game_dll_source,
|
game_dll_source,
|
||||||
legacy.links_created,
|
legacy.links_created,
|
||||||
@@ -691,16 +808,23 @@ def _reuse_runtime_view(
|
|||||||
paths: AppPaths,
|
paths: AppPaths,
|
||||||
source: Path,
|
source: Path,
|
||||||
game_save: Path,
|
game_save: Path,
|
||||||
|
game_config: Path,
|
||||||
) -> OverlayResult | None:
|
) -> OverlayResult | None:
|
||||||
result = _matching_cached_runtime_result(paths, source)
|
result = _matching_cached_runtime_result(paths, source)
|
||||||
if result is None:
|
if result is None:
|
||||||
return None
|
return None
|
||||||
game_exe_source = source / "Game.exe"
|
game_exe_source = source / "Game.exe"
|
||||||
game_dll_source = source / "game.dll"
|
game_dll_source = source / "game.dll"
|
||||||
|
_migrate_runtime_game_config_link(
|
||||||
|
paths.runtime_view / "game.ini",
|
||||||
|
source / "game.ini",
|
||||||
|
game_config,
|
||||||
|
)
|
||||||
if not _runtime_layout_ready(
|
if not _runtime_layout_ready(
|
||||||
paths,
|
paths,
|
||||||
source,
|
source,
|
||||||
game_save,
|
game_save,
|
||||||
|
game_config,
|
||||||
game_exe_source,
|
game_exe_source,
|
||||||
game_dll_source,
|
game_dll_source,
|
||||||
result.links_created,
|
result.links_created,
|
||||||
@@ -724,6 +848,7 @@ def _runtime_layout_ready(
|
|||||||
paths: AppPaths,
|
paths: AppPaths,
|
||||||
source: Path,
|
source: Path,
|
||||||
game_save: Path,
|
game_save: Path,
|
||||||
|
game_config: Path,
|
||||||
game_exe_source: Path,
|
game_exe_source: Path,
|
||||||
game_dll_source: Path,
|
game_dll_source: Path,
|
||||||
expected_links: int,
|
expected_links: int,
|
||||||
@@ -731,8 +856,8 @@ def _runtime_layout_ready(
|
|||||||
runtime = paths.runtime_view
|
runtime = paths.runtime_view
|
||||||
if runtime.is_symlink() or not runtime.is_dir():
|
if runtime.is_symlink() or not runtime.is_dir():
|
||||||
return False
|
return False
|
||||||
top_level_exceptions = {"game", "save"}
|
top_level_exceptions = {"game", "game.ini", "save"}
|
||||||
top_level_special = {"game", "save"}
|
top_level_special = {"game", "game.ini", "save"}
|
||||||
for name, binary_source in (
|
for name, binary_source in (
|
||||||
("Game.exe", game_exe_source),
|
("Game.exe", game_exe_source),
|
||||||
("game.dll", game_dll_source),
|
("game.dll", game_dll_source),
|
||||||
@@ -750,6 +875,8 @@ def _runtime_layout_ready(
|
|||||||
return False
|
return False
|
||||||
if not _symlink_points_to(runtime / "save", game_save):
|
if not _symlink_points_to(runtime / "save", game_save):
|
||||||
return False
|
return False
|
||||||
|
if not _symlink_points_to(runtime / "game.ini", game_config):
|
||||||
|
return False
|
||||||
if game_exe_source != source / "Game.exe" and not _symlink_points_to(
|
if game_exe_source != source / "Game.exe" and not _symlink_points_to(
|
||||||
runtime / "Game.exe",
|
runtime / "Game.exe",
|
||||||
game_exe_source,
|
game_exe_source,
|
||||||
@@ -789,7 +916,7 @@ def _runtime_layout_ready(
|
|||||||
runtime_scene = maps_target / "WhisperingVale.rovug"
|
runtime_scene = maps_target / "WhisperingVale.rovug"
|
||||||
if runtime_scene.is_symlink() or not runtime_scene.is_file():
|
if runtime_scene.is_symlink() or not runtime_scene.is_file():
|
||||||
return False
|
return False
|
||||||
actual_links = top_level_links + game_links + maps_links + 1
|
actual_links = top_level_links + game_links + maps_links + 2
|
||||||
if game_exe_source != source / "Game.exe":
|
if game_exe_source != source / "Game.exe":
|
||||||
actual_links += 1
|
actual_links += 1
|
||||||
if game_dll_source != source / "game.dll":
|
if game_dll_source != source / "game.dll":
|
||||||
@@ -830,6 +957,27 @@ def _symlink_points_to(link: Path, expected_target: Path) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_runtime_game_config_link(
|
||||||
|
link: Path,
|
||||||
|
source_config: Path,
|
||||||
|
persistent_config: Path,
|
||||||
|
) -> bool:
|
||||||
|
"""Atomically redirect the previous source link without rebuilding the view."""
|
||||||
|
|
||||||
|
if _symlink_points_to(link, persistent_config):
|
||||||
|
return True
|
||||||
|
if not _symlink_points_to(link, source_config):
|
||||||
|
return False
|
||||||
|
temporary = link.with_name(f".{link.name}.{uuid.uuid4().hex}.tmp")
|
||||||
|
try:
|
||||||
|
temporary.symlink_to(persistent_config)
|
||||||
|
temporary.replace(link)
|
||||||
|
except OSError:
|
||||||
|
temporary.unlink(missing_ok=True)
|
||||||
|
return False
|
||||||
|
return _symlink_points_to(link, persistent_config)
|
||||||
|
|
||||||
|
|
||||||
def _read_overlay_result(path: Path) -> OverlayResult | None:
|
def _read_overlay_result(path: Path) -> OverlayResult | None:
|
||||||
try:
|
try:
|
||||||
value = json.loads(path.read_text(encoding="utf-8"))
|
value = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
|||||||
@@ -208,6 +208,12 @@ class AppPaths:
|
|||||||
def game_save(self) -> Path:
|
def game_save(self) -> Path:
|
||||||
return self.game_user_data / "save"
|
return self.game_user_data / "save"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def game_config(self) -> Path:
|
||||||
|
"""Writable game settings kept outside the launcher-managed source tree."""
|
||||||
|
|
||||||
|
return self.game_user_data / "game.ini"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def compatibility_binaries(self) -> Path:
|
def compatibility_binaries(self) -> Path:
|
||||||
return self.data / "compatibility-binaries"
|
return self.data / "compatibility-binaries"
|
||||||
|
|||||||
Reference in New Issue
Block a user