Fixed issues with windowed mode and fullscreen.

This commit is contained in:
2026-09-02 20:55:00 +02:00
parent 55b2292bfe
commit 6f20b5e359
3 changed files with 368 additions and 39 deletions

View File

@@ -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:
nudge_confirmed = self._resize_window_and_wait(
window,
(nudged_width, nudged_height),
timeout_seconds=WINDOW_NUDGE_CONFIRM_TIMEOUT_SECONDS,
)
if nudge_confirmed:
time.sleep(WINDOW_SURFACE_REFRESH_DELAY_SECONDS) time.sleep(WINDOW_SURFACE_REFRESH_DELAY_SECONDS)
finally: finally:
# Always restore the exact starting dimensions if the short wait is # Always request and verify the exact starting dimensions, including when
# interrupted. The game remains mapped for the entire operation. # KWin declined the nudge or the short wait was interrupted.
self.library.XResizeWindow( restore_confirmed = self._resize_window_and_wait(
self.display,
window, window,
original_width, (original_width, original_height),
original_height,
) )
self.library.XSync(self.display, 0) return nudge_confirmed and restore_confirmed
return True
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,

View File

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

View File

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