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

@@ -45,6 +45,10 @@ RETIRED_POST_AUTH_GAME_DLL_SHA256 = (
"93cd27284eb294753da1111c99bfc9689f82b6d4a59302aee9fdbd8cb52452dd"
)
RUNTIME_VIEW_CONFIGURATION_VERSION = 2
WINDOWED_GAME_CONFIG_VALUES = (
(b"windowed", b"Windowed", b"1"),
(b"startmaximized", b"StartMaximized", b"False"),
)
class OverlayError(RuntimeError):
@@ -175,7 +179,8 @@ def prepare_runtime_view(
_validate_game_tree(source)
_validate_runtime_target(paths)
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:
return reused
input_identities = _runtime_input_identities(paths, source)
@@ -197,6 +202,7 @@ def prepare_runtime_view(
inspection,
action,
game_save,
game_config,
binary_action,
game_exe_source,
game_dll_source,
@@ -211,7 +217,7 @@ def prepare_runtime_view(
links_created = 0
try:
build.mkdir(parents=True, exist_ok=False)
top_level_exceptions = {"game", "save"}
top_level_exceptions = {"game", "game.ini", "save"}
for name, binary_source in (
("Game.exe", game_exe_source),
("game.dll", game_dll_source),
@@ -225,6 +231,8 @@ def prepare_runtime_view(
)
(build / "save").symlink_to(game_save, target_is_directory=True)
links_created += 1
(build / "game.ini").symlink_to(game_config)
links_created += 1
if game_exe_source != source / "Game.exe":
(build / "Game.exe").symlink_to(game_exe_source)
links_created += 1
@@ -510,6 +518,108 @@ def _prepare_persistent_save(paths: AppPaths, source: Path) -> Path:
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:
for directory, directory_names, file_names in os.walk(root, followlinks=False):
parent = Path(directory)
@@ -617,6 +727,7 @@ def _reuse_legacy_runtime_view(
inspection: SceneInspection,
scene_action: str,
game_save: Path,
game_config: Path,
binary_action: str,
game_exe_source: Path,
game_dll_source: Path,
@@ -640,10 +751,16 @@ def _reuse_legacy_runtime_view(
return None
if legacy.game_dll_sha256 != source_binary_hashes.get("game.dll"):
return None
_migrate_runtime_game_config_link(
paths.runtime_view / "game.ini",
source / "game.ini",
game_config,
)
if not _runtime_layout_ready(
paths,
source,
game_save,
game_config,
game_exe_source,
game_dll_source,
legacy.links_created,
@@ -691,16 +808,23 @@ def _reuse_runtime_view(
paths: AppPaths,
source: Path,
game_save: Path,
game_config: Path,
) -> OverlayResult | None:
result = _matching_cached_runtime_result(paths, source)
if result is None:
return None
game_exe_source = source / "Game.exe"
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(
paths,
source,
game_save,
game_config,
game_exe_source,
game_dll_source,
result.links_created,
@@ -724,6 +848,7 @@ def _runtime_layout_ready(
paths: AppPaths,
source: Path,
game_save: Path,
game_config: Path,
game_exe_source: Path,
game_dll_source: Path,
expected_links: int,
@@ -731,8 +856,8 @@ def _runtime_layout_ready(
runtime = paths.runtime_view
if runtime.is_symlink() or not runtime.is_dir():
return False
top_level_exceptions = {"game", "save"}
top_level_special = {"game", "save"}
top_level_exceptions = {"game", "game.ini", "save"}
top_level_special = {"game", "game.ini", "save"}
for name, binary_source in (
("Game.exe", game_exe_source),
("game.dll", game_dll_source),
@@ -750,6 +875,8 @@ def _runtime_layout_ready(
return False
if not _symlink_points_to(runtime / "save", game_save):
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(
runtime / "Game.exe",
game_exe_source,
@@ -789,7 +916,7 @@ def _runtime_layout_ready(
runtime_scene = maps_target / "WhisperingVale.rovug"
if runtime_scene.is_symlink() or not runtime_scene.is_file():
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":
actual_links += 1
if game_dll_source != source / "game.dll":
@@ -830,6 +957,27 @@ def _symlink_points_to(link: Path, expected_target: Path) -> bool:
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:
try:
value = json.loads(path.read_text(encoding="utf-8"))