diff --git a/ptdiag/experiment.py b/ptdiag/experiment.py index 141d5ba..b37f371 100644 --- a/ptdiag/experiment.py +++ b/ptdiag/experiment.py @@ -1095,6 +1095,7 @@ def _run_and_watch( detected_at_ns: int | None = None timed_out = False stop_reason = "process-exited" + failure_reason: str | None = None with runner_log_path.open("a", encoding="utf-8") as runner_log: try: process = subprocess.Popen( @@ -1151,18 +1152,26 @@ def _run_and_watch( current_state = state current_analysis = analysis.to_summary() 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_trigger_mtime_ns = state.mtime_ns detected_at_ns = now_ns post_failure_deadline_ns = now_ns + int( 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 - if post_failure_deadline_ns is not None and now_ns >= post_failure_deadline_ns: - stop_reason = "load-texture-to-material" - _stop_process_group(process) - break + if post_failure_deadline_ns is not None and now_ns >= post_failure_deadline_ns: + stop_reason = failure_reason or "load-texture-to-material" + _stop_process_group(process) + break if now_ns >= deadline_ns: timed_out = True stop_reason = "timeout" @@ -1216,6 +1225,9 @@ def _run_and_watch( "load_texture_to_material_failed": analysis.get( "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), } ) @@ -1244,6 +1256,7 @@ def _run_and_watch( whispering_vale and not timed_out 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("failed_import_scenes", []) ) if isinstance(current_analysis, Mapping) else False diff --git a/ptdiag/logs.py b/ptdiag/logs.py index 1833c18..bbf3fba 100644 --- a/ptdiag/logs.py +++ b/ptdiag/logs.py @@ -9,6 +9,7 @@ from typing import Iterable, Mapping DEFAULT_NATIVE_LOG_PATTERNS = ("log_*.txt", "*.log") +_ASSET_MISSING_FILE_THRESHOLD = 200 _SCENE_RE = re.compile( r"\[S1]\s+after import\s+(?P[^:]+):\s*" @@ -72,6 +73,12 @@ class NativeLogAnalysis: scenes: dict[str, list[SceneImport]] = field(default_factory=dict) events: 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) load_texture_to_material_failed: bool = False import_scene_threw: bool = False @@ -83,6 +90,14 @@ class NativeLogAnalysis: module_code_sync_delta: int | None = None 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: wanted = _scene_key(name) 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, "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_files": [event.to_dict() for event in self.failed_files], "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) analysis.failed_files.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: analysis.stage_without_new_stage = True @@ -319,3 +353,61 @@ def _scene_key(value: str) -> str: def _timestamp(line: str) -> str | None: match = _TIMESTAMP_RE.match(line) 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") + ) diff --git a/ptlinux/cli.py b/ptlinux/cli.py index fcd57ae..1eb76c3 100644 --- a/ptlinux/cli.py +++ b/ptlinux/cli.py @@ -1046,6 +1046,7 @@ def _current_game_log( "size": state.size, "load_texture_to_material_failed": analysis.load_texture_to_material_failed, "import_scene_threw": analysis.import_scene_threw, + "asset_pack_health_failed": analysis.asset_pack_health_failed, "failed_import_scenes": analysis.failed_import_scenes, "stage_without_new_stage": analysis.stage_without_new_stage, "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 " "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": [ event.to_dict() 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.", 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"]: print( "Detected a texture/material failure while loading the game world.", diff --git a/ptlinux/gamebox_runtime.py b/ptlinux/gamebox_runtime.py index 82b145d..7ee18ac 100644 --- a/ptlinux/gamebox_runtime.py +++ b/ptlinux/gamebox_runtime.py @@ -46,6 +46,7 @@ _X_SUBSTRUCTURE_REDIRECT_MASK = 1 << 20 _X_WM_STATE_ICONIC = 3 _NET_WM_STATE_REMOVE = 0 _NET_WM_STATE_SOURCE_APPLICATION = 1 +WINDOW_FOCUS_REPAIR_INTERVAL_SECONDS = 1.0 _P_MIN_SIZE = 1 << 4 _P_MAX_SIZE = 1 << 5 _MOTIF_HINTS_FUNCTIONS = 1 << 0 @@ -466,7 +467,20 @@ class _X11: and updated_normal_hints != normal_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) confirmed = False @@ -485,6 +499,13 @@ class _X11: b"_MOTIF_WM_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: self.library.XFlush(self.display) confirmed = True @@ -515,6 +536,16 @@ class _X11: ) except Exception as 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: self.library.XSync(self.display, 0) except Exception as exc: @@ -546,6 +577,36 @@ class _X11: vertical, }.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: deadline = time.monotonic() + WINDOW_ACTION_CONFIRM_TIMEOUT_SECONDS while not self.window_allows_scaling(window): @@ -595,6 +656,7 @@ class _X11: watched_atoms = { 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"_NET_WM_ALLOWED_ACTIONS", only_if_exists=False), } changed = False events_read = 0 @@ -831,6 +893,17 @@ class _X11: ) 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: """Ask the window manager to restore and activate one client.""" @@ -1111,6 +1184,12 @@ class _X11: ctypes.c_ulong, ] 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 = [ ctypes.c_void_p, 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: """Enable confirmed resize/maximize actions without risking game startup.""" @@ -1301,7 +1401,8 @@ def _maintain_game_window_scaling( return int(process.wait()) 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: try: hints_changed = mapper.wait_for_scaling_hint_change(window) @@ -1313,13 +1414,17 @@ def _maintain_game_window_scaling( flush=True, ) break - if not hints_changed: - continue now = time.monotonic() - if now < next_repair_at: - time.sleep(next_repair_at - now) + if now >= next_focus_repair_at: + _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: - mapper.enable_window_scaling(window, confirm=False) + mapper.enable_window_scaling( + window, + confirm=False, + ) except Exception as exc: repair_failures += 1 print( @@ -1337,7 +1442,7 @@ def _maintain_game_window_scaling( break else: 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()) @@ -1517,7 +1622,7 @@ def run_game_window( game_window, borderless=borderless, ) - if startup_stable and not borderless: + if not borderless: return _maintain_game_window_scaling( mapper, process,