Reworked some umu / proton 7 maximize window and keyboard issues.

This commit is contained in:
2026-09-04 23:39:43 +02:00
parent 8fe627b6cf
commit 09c8f165be
4 changed files with 242 additions and 14 deletions

View File

@@ -1095,6 +1095,7 @@ def _run_and_watch(
detected_at_ns: int | None = None detected_at_ns: int | None = None
timed_out = False timed_out = False
stop_reason = "process-exited" stop_reason = "process-exited"
failure_reason: str | None = None
with runner_log_path.open("a", encoding="utf-8") as runner_log: with runner_log_path.open("a", encoding="utf-8") as runner_log:
try: try:
process = subprocess.Popen( process = subprocess.Popen(
@@ -1151,18 +1152,26 @@ def _run_and_watch(
current_state = state current_state = state
current_analysis = analysis.to_summary() current_analysis = analysis.to_summary()
current_text = read_current_run_log_text(state, baseline) current_text = read_current_run_log_text(state, baseline)
if analysis.load_texture_to_material_failed: if (
analysis.load_texture_to_material_failed
or analysis.asset_pack_health_failed
):
failure_state = state failure_state = state
failure_trigger_mtime_ns = state.mtime_ns failure_trigger_mtime_ns = state.mtime_ns
detected_at_ns = now_ns detected_at_ns = now_ns
post_failure_deadline_ns = now_ns + int( post_failure_deadline_ns = now_ns + int(
testcase.stop_after_failure_seconds * 1_000_000_000 testcase.stop_after_failure_seconds * 1_000_000_000
) )
failure_reason = (
"asset-pack-health"
if analysis.asset_pack_health_failed
else "load-texture-to-material"
)
break break
if post_failure_deadline_ns is not None and now_ns >= post_failure_deadline_ns: if post_failure_deadline_ns is not None and now_ns >= post_failure_deadline_ns:
stop_reason = "load-texture-to-material" stop_reason = failure_reason or "load-texture-to-material"
_stop_process_group(process) _stop_process_group(process)
break break
if now_ns >= deadline_ns: if now_ns >= deadline_ns:
timed_out = True timed_out = True
stop_reason = "timeout" stop_reason = "timeout"
@@ -1216,6 +1225,9 @@ def _run_and_watch(
"load_texture_to_material_failed": analysis.get( "load_texture_to_material_failed": analysis.get(
"load_texture_to_material_failed", False "load_texture_to_material_failed", False
), ),
"asset_pack_health_failed": analysis.get(
"asset_pack_health_failed", False
),
"import_scene_threw": analysis.get("import_scene_threw", False), "import_scene_threw": analysis.get("import_scene_threw", False),
} }
) )
@@ -1244,6 +1256,7 @@ def _run_and_watch(
whispering_vale whispering_vale
and not timed_out and not timed_out
and not current_analysis.get("load_texture_to_material_failed", False) and not current_analysis.get("load_texture_to_material_failed", False)
and not current_analysis.get("asset_pack_health_failed", False)
and not current_analysis.get("import_scene_threw", False) and not current_analysis.get("import_scene_threw", False)
and not current_analysis.get("failed_import_scenes", []) and not current_analysis.get("failed_import_scenes", [])
) if isinstance(current_analysis, Mapping) else False ) if isinstance(current_analysis, Mapping) else False

View File

@@ -9,6 +9,7 @@ from typing import Iterable, Mapping
DEFAULT_NATIVE_LOG_PATTERNS = ("log_*.txt", "*.log") DEFAULT_NATIVE_LOG_PATTERNS = ("log_*.txt", "*.log")
_ASSET_MISSING_FILE_THRESHOLD = 200
_SCENE_RE = re.compile( _SCENE_RE = re.compile(
r"\[S1]\s+after import\s+(?P<scene>[^:]+):\s*" r"\[S1]\s+after import\s+(?P<scene>[^:]+):\s*"
@@ -72,6 +73,12 @@ class NativeLogAnalysis:
scenes: dict[str, list[SceneImport]] = field(default_factory=dict) scenes: dict[str, list[SceneImport]] = field(default_factory=dict)
events: list[LogEvent] = field(default_factory=list) events: list[LogEvent] = field(default_factory=list)
failed_files: list[LogEvent] = field(default_factory=list) failed_files: list[LogEvent] = field(default_factory=list)
failed_file_find_count: int = 0
failed_file_load_count: int = 0
failed_file_read_count: int = 0
failed_asset_file_count: int = 0
failed_texture_file_count: int = 0
failed_effect_file_count: int = 0
failed_import_scenes: list[str] = field(default_factory=list) failed_import_scenes: list[str] = field(default_factory=list)
load_texture_to_material_failed: bool = False load_texture_to_material_failed: bool = False
import_scene_threw: bool = False import_scene_threw: bool = False
@@ -83,6 +90,14 @@ class NativeLogAnalysis:
module_code_sync_delta: int | None = None module_code_sync_delta: int | None = None
banned_code_1: bool = False banned_code_1: bool = False
@property
def asset_pack_health_failed(self) -> bool:
return (
self.failed_asset_file_count >= _ASSET_MISSING_FILE_THRESHOLD
or self.failed_texture_file_count >= _ASSET_MISSING_FILE_THRESHOLD // 3
or self.failed_effect_file_count >= max(25, _ASSET_MISSING_FILE_THRESHOLD // 8)
)
def latest_scene(self, name: str) -> SceneImport | None: def latest_scene(self, name: str) -> SceneImport | None:
wanted = _scene_key(name) wanted = _scene_key(name)
for scene_name, imports in self.scenes.items(): for scene_name, imports in self.scenes.items():
@@ -98,6 +113,13 @@ class NativeLogAnalysis:
}, },
"load_texture_to_material_failed": self.load_texture_to_material_failed, "load_texture_to_material_failed": self.load_texture_to_material_failed,
"import_scene_threw": self.import_scene_threw, "import_scene_threw": self.import_scene_threw,
"failed_file_find_count": self.failed_file_find_count,
"failed_file_load_count": self.failed_file_load_count,
"failed_file_read_count": self.failed_file_read_count,
"failed_asset_file_count": self.failed_asset_file_count,
"failed_texture_file_count": self.failed_texture_file_count,
"failed_effect_file_count": self.failed_effect_file_count,
"asset_pack_health_failed": self.asset_pack_health_failed,
"failed_import_scenes": self.failed_import_scenes, "failed_import_scenes": self.failed_import_scenes,
"failed_files": [event.to_dict() for event in self.failed_files], "failed_files": [event.to_dict() for event in self.failed_files],
"stage_without_new_stage": self.stage_without_new_stage, "stage_without_new_stage": self.stage_without_new_stage,
@@ -291,6 +313,18 @@ def parse_native_log_lines(lines: Iterable[str]) -> NativeLogAnalysis:
event = LogEvent(f"failed_{operation}_file", line_number, raw, timestamp, value) event = LogEvent(f"failed_{operation}_file", line_number, raw, timestamp, value)
analysis.failed_files.append(event) analysis.failed_files.append(event)
analysis.events.append(event) analysis.events.append(event)
if operation == "find":
analysis.failed_file_find_count += 1
elif operation == "load":
analysis.failed_file_load_count += 1
else:
analysis.failed_file_read_count += 1
if _is_game_asset_path(value):
analysis.failed_asset_file_count += 1
if _is_texture_path(value):
analysis.failed_texture_file_count += 1
if _is_effect_path(value):
analysis.failed_effect_file_count += 1
if "Stage without new stage" in raw: if "Stage without new stage" in raw:
analysis.stage_without_new_stage = True analysis.stage_without_new_stage = True
@@ -319,3 +353,61 @@ def _scene_key(value: str) -> str:
def _timestamp(line: str) -> str | None: def _timestamp(line: str) -> str | None:
match = _TIMESTAMP_RE.match(line) match = _TIMESTAMP_RE.match(line)
return match.group("timestamp") if match is not None else None return match.group("timestamp") if match is not None else None
def _is_game_asset_path(path: str) -> bool:
lower = path.casefold()
if not lower:
return False
if lower.startswith("save/") or lower.startswith("save\\"):
return False
if lower.startswith(("game\\", "game/")):
return True
if lower.startswith(("field\\", "field/")):
return True
if lower.startswith(("image\\", "image/")):
return True
if lower.startswith(("effect\\", "effect/")):
return True
if lower.startswith(("startimage\\", "startimage/")):
return True
return (
"\\effect\\" in lower
or "\\image\\" in lower
or "\\field\\" in lower
or "\\weapons\\" in lower
or "\\char\\" in lower
or "\\sinimage\\" in lower
or "\\skill\\" in lower
or lower.endswith(".bmp")
or lower.endswith(".tga")
or lower.endswith(".png")
or lower.endswith(".jpg")
or lower.endswith(".jpeg")
or lower.endswith(".gif")
or lower.endswith(".ase")
or lower.endswith(".part")
)
def _is_texture_path(path: str) -> bool:
lower = path.casefold()
return (
lower.endswith(".bmp")
or lower.endswith(".tga")
or lower.endswith(".png")
or lower.endswith(".jpg")
or lower.endswith(".jpeg")
or lower.endswith(".gif")
or lower.endswith(".dds")
)
def _is_effect_path(path: str) -> bool:
lower = path.casefold()
return (
"\\effect\\" in lower
or lower.startswith("effect\\")
or lower.startswith("effect/")
or lower.endswith(".part")
)

View File

@@ -1046,6 +1046,7 @@ def _current_game_log(
"size": state.size, "size": state.size,
"load_texture_to_material_failed": analysis.load_texture_to_material_failed, "load_texture_to_material_failed": analysis.load_texture_to_material_failed,
"import_scene_threw": analysis.import_scene_threw, "import_scene_threw": analysis.import_scene_threw,
"asset_pack_health_failed": analysis.asset_pack_health_failed,
"failed_import_scenes": analysis.failed_import_scenes, "failed_import_scenes": analysis.failed_import_scenes,
"stage_without_new_stage": analysis.stage_without_new_stage, "stage_without_new_stage": analysis.stage_without_new_stage,
"module_code_sync_error": analysis.module_code_sync_error, "module_code_sync_error": analysis.module_code_sync_error,
@@ -1058,6 +1059,12 @@ def _current_game_log(
"native-game-log-only; server-provided login-screen text is not " "native-game-log-only; server-provided login-screen text is not "
"written to this log" "written to this log"
), ),
"failed_file_find_count": analysis.failed_file_find_count,
"failed_file_load_count": analysis.failed_file_load_count,
"failed_file_read_count": analysis.failed_file_read_count,
"failed_asset_file_count": analysis.failed_asset_file_count,
"failed_texture_file_count": analysis.failed_texture_file_count,
"failed_effect_file_count": analysis.failed_effect_file_count,
"stage_without_new_stage_events": [ "stage_without_new_stage_events": [
event.to_dict() event.to_dict()
for event in analysis.events for event in analysis.events
@@ -1162,6 +1169,17 @@ def _print_interrupted_game_log(report: dict[str, object]) -> None:
"with publisher-pristine code in this launcher version.", "with publisher-pristine code in this launcher version.",
file=sys.stderr, file=sys.stderr,
) )
elif report["asset_pack_health_failed"]:
print(
"Detected a large number of missing game-asset files. This indicates a "
"corrupted or incomplete installation that needs repair.",
file=sys.stderr,
)
print(
"Run `pristontale-eu update --repair` and restart. If the issue "
"reappears, re-download the game files with a clean install path.",
file=sys.stderr,
)
elif report["load_texture_to_material_failed"]: elif report["load_texture_to_material_failed"]:
print( print(
"Detected a texture/material failure while loading the game world.", "Detected a texture/material failure while loading the game world.",

View File

@@ -46,6 +46,7 @@ _X_SUBSTRUCTURE_REDIRECT_MASK = 1 << 20
_X_WM_STATE_ICONIC = 3 _X_WM_STATE_ICONIC = 3
_NET_WM_STATE_REMOVE = 0 _NET_WM_STATE_REMOVE = 0
_NET_WM_STATE_SOURCE_APPLICATION = 1 _NET_WM_STATE_SOURCE_APPLICATION = 1
WINDOW_FOCUS_REPAIR_INTERVAL_SECONDS = 1.0
_P_MIN_SIZE = 1 << 4 _P_MIN_SIZE = 1 << 4
_P_MAX_SIZE = 1 << 5 _P_MAX_SIZE = 1 << 5
_MOTIF_HINTS_FUNCTIONS = 1 << 0 _MOTIF_HINTS_FUNCTIONS = 1 << 0
@@ -466,7 +467,20 @@ class _X11:
and updated_normal_hints != normal_hints and updated_normal_hints != normal_hints
) )
motif_changed = motif_hints is not None and updated_motif_hints != motif_hints motif_changed = motif_hints is not None and updated_motif_hints != motif_hints
if not normal_changed and not motif_changed: allowed_actions = self._long_property(window, b"_NET_WM_ALLOWED_ACTIONS", 64)
allowed_actions_with_scaling = self._allowed_actions_with_scaling(
allowed_actions,
)
allowed_actions_changed = (
allowed_actions is not None
and allowed_actions_with_scaling is not None
and allowed_actions_with_scaling != allowed_actions
)
if (
not normal_changed
and not motif_changed
and not allowed_actions_changed
):
return not confirm or self.window_allows_scaling(window) return not confirm or self.window_allows_scaling(window)
confirmed = False confirmed = False
@@ -485,6 +499,13 @@ class _X11:
b"_MOTIF_WM_HINTS", b"_MOTIF_WM_HINTS",
updated_motif_hints, updated_motif_hints,
) )
if allowed_actions_changed and allowed_actions_with_scaling is not None:
self._replace_long_property(
window,
b"_NET_WM_ALLOWED_ACTIONS",
b"_ATOM",
allowed_actions_with_scaling,
)
if not confirm: if not confirm:
self.library.XFlush(self.display) self.library.XFlush(self.display)
confirmed = True confirmed = True
@@ -515,6 +536,16 @@ class _X11:
) )
except Exception as exc: except Exception as exc:
restore_errors.append(exc) restore_errors.append(exc)
if allowed_actions_changed and allowed_actions is not None:
try:
self._replace_long_property(
window,
b"_NET_WM_ALLOWED_ACTIONS",
b"_ATOM",
allowed_actions,
)
except Exception as exc:
restore_errors.append(exc)
try: try:
self.library.XSync(self.display, 0) self.library.XSync(self.display, 0)
except Exception as exc: except Exception as exc:
@@ -546,6 +577,36 @@ class _X11:
vertical, vertical,
}.issubset(actions) }.issubset(actions)
def _allowed_actions_with_scaling(
self,
actions: tuple[int, ...] | None,
) -> tuple[int, ...] | None:
if actions is None:
return None
resize = self._intern_atom(
b"_NET_WM_ACTION_RESIZE",
only_if_exists=True,
)
horizontal = self._intern_atom(
b"_NET_WM_ACTION_MAXIMIZE_HORZ",
only_if_exists=True,
)
vertical = self._intern_atom(
b"_NET_WM_ACTION_MAXIMIZE_VERT",
only_if_exists=True,
)
if resize == 0 or horizontal == 0 or vertical == 0:
return actions
updated_actions = list(actions)
known_actions = set(actions)
for atom in (resize, horizontal, vertical):
if atom not in known_actions:
updated_actions.append(atom)
known_actions.add(atom)
return tuple(updated_actions)
def _wait_for_scaling_actions(self, window: int) -> bool: def _wait_for_scaling_actions(self, window: int) -> bool:
deadline = time.monotonic() + WINDOW_ACTION_CONFIRM_TIMEOUT_SECONDS deadline = time.monotonic() + WINDOW_ACTION_CONFIRM_TIMEOUT_SECONDS
while not self.window_allows_scaling(window): while not self.window_allows_scaling(window):
@@ -595,6 +656,7 @@ class _X11:
watched_atoms = { watched_atoms = {
self._intern_atom(b"WM_NORMAL_HINTS", only_if_exists=False), self._intern_atom(b"WM_NORMAL_HINTS", only_if_exists=False),
self._intern_atom(b"_MOTIF_WM_HINTS", only_if_exists=False), self._intern_atom(b"_MOTIF_WM_HINTS", only_if_exists=False),
self._intern_atom(b"_NET_WM_ALLOWED_ACTIONS", only_if_exists=False),
} }
changed = False changed = False
events_read = 0 events_read = 0
@@ -831,6 +893,17 @@ class _X11:
) )
self.library.XSync(self.display, 0) self.library.XSync(self.display, 0)
def window_is_focused(self, window: int) -> bool:
focused = ctypes.c_ulong()
revert_to = ctypes.c_int()
if self.library.XGetInputFocus(
self.display,
ctypes.byref(focused),
ctypes.byref(revert_to),
) != 1:
return False
return int(focused.value) == window
def activate_window(self, window: int) -> bool: def activate_window(self, window: int) -> bool:
"""Ask the window manager to restore and activate one client.""" """Ask the window manager to restore and activate one client."""
@@ -1111,6 +1184,12 @@ class _X11:
ctypes.c_ulong, ctypes.c_ulong,
] ]
self.library.XSetInputFocus.restype = ctypes.c_int self.library.XSetInputFocus.restype = ctypes.c_int
self.library.XGetInputFocus.argtypes = [
ctypes.c_void_p,
ctypes.POINTER(ctypes.c_ulong),
ctypes.POINTER(ctypes.c_int),
]
self.library.XGetInputFocus.restype = ctypes.c_int
self.library.XSendEvent.argtypes = [ self.library.XSendEvent.argtypes = [
ctypes.c_void_p, ctypes.c_void_p,
ctypes.c_ulong, ctypes.c_ulong,
@@ -1250,6 +1329,27 @@ def _focus_game_window(mapper: _X11, window: int) -> None:
) )
def _repair_game_window_focus(mapper: _X11, window: int) -> bool:
try:
if mapper.window_is_focused(window):
return True
except Exception:
return False
try:
mapper.activate_window(window)
time.sleep(WINDOW_ACTIVATION_DELAY_SECONDS)
mapper.focus_window(window)
return mapper.window_is_focused(window)
except Exception as exc:
print(
f"Warning: could not repair game-window focus ({exc}); continuing "
"normally",
file=sys.stderr,
flush=True,
)
return False
def _enable_game_window_scaling(mapper: _X11, window: int) -> bool: def _enable_game_window_scaling(mapper: _X11, window: int) -> bool:
"""Enable confirmed resize/maximize actions without risking game startup.""" """Enable confirmed resize/maximize actions without risking game startup."""
@@ -1301,7 +1401,8 @@ def _maintain_game_window_scaling(
return int(process.wait()) return int(process.wait())
repair_failures = 0 repair_failures = 0
next_repair_at = 0.0 next_scaling_repair_at = 0.0
next_focus_repair_at = time.monotonic() + WINDOW_FOCUS_REPAIR_INTERVAL_SECONDS
while process.poll() is None: while process.poll() is None:
try: try:
hints_changed = mapper.wait_for_scaling_hint_change(window) hints_changed = mapper.wait_for_scaling_hint_change(window)
@@ -1313,13 +1414,17 @@ def _maintain_game_window_scaling(
flush=True, flush=True,
) )
break break
if not hints_changed:
continue
now = time.monotonic() now = time.monotonic()
if now < next_repair_at: if now >= next_focus_repair_at:
time.sleep(next_repair_at - now) _repair_game_window_focus(mapper, window)
next_focus_repair_at = time.monotonic() + WINDOW_FOCUS_REPAIR_INTERVAL_SECONDS
if not hints_changed and now < next_scaling_repair_at:
continue
try: try:
mapper.enable_window_scaling(window, confirm=False) mapper.enable_window_scaling(
window,
confirm=False,
)
except Exception as exc: except Exception as exc:
repair_failures += 1 repair_failures += 1
print( print(
@@ -1337,7 +1442,7 @@ def _maintain_game_window_scaling(
break break
else: else:
repair_failures = 0 repair_failures = 0
next_repair_at = time.monotonic() + WINDOW_SCALING_REPAIR_INTERVAL_SECONDS next_scaling_repair_at = time.monotonic() + WINDOW_SCALING_REPAIR_INTERVAL_SECONDS
return int(process.wait()) return int(process.wait())
@@ -1517,7 +1622,7 @@ def run_game_window(
game_window, game_window,
borderless=borderless, borderless=borderless,
) )
if startup_stable and not borderless: if not borderless:
return _maintain_game_window_scaling( return _maintain_game_window_scaling(
mapper, mapper,
process, process,