|
46 | 46 | # still works. Each env instance owns its own mss handle (mss is not thread-safe). |
47 | 47 | import cv2 |
48 | 48 | import mss |
49 | | -import win32gui |
50 | | -import win32process |
| 49 | +try: |
| 50 | + import win32gui |
| 51 | +except ImportError: |
| 52 | + win32gui = None |
| 53 | +try: |
| 54 | + import win32process |
| 55 | +except ImportError: |
| 56 | + win32process = None |
51 | 57 |
|
52 | 58 |
|
53 | 59 | def _capture_noita_frame() -> "Optional[Image.Image]": |
@@ -88,6 +94,66 @@ def _cb(hwnd: int, _lparam) -> bool: |
88 | 94 | return found[0] if found else None |
89 | 95 |
|
90 | 96 |
|
| 97 | + |
| 98 | +def _dismiss_error_dialog(target_pid: Optional[int] = None) -> bool: |
| 99 | + """ |
| 100 | + Searches for Noita error/crash dialogs and clicks the 'Always Ignore' |
| 101 | + button if present to prevent the training from hanging. |
| 102 | + """ |
| 103 | + BM_CLICK = 0x00F5 |
| 104 | + clicked = False |
| 105 | + |
| 106 | + def _enum_child_cb(child_hwnd: int, _lparam) -> bool: |
| 107 | + nonlocal clicked |
| 108 | + if not win32gui.IsWindowVisible(child_hwnd): |
| 109 | + return True |
| 110 | + child_title = win32gui.GetWindowText(child_hwnd) or "" |
| 111 | + child_title_lower = child_title.lower() |
| 112 | + if "ignore" in child_title_lower and "always" in child_title_lower: |
| 113 | + try: |
| 114 | + # SendMessage is blocking, PostMessage is async |
| 115 | + import win32con |
| 116 | + import win32api |
| 117 | + win32api.PostMessage(child_hwnd, BM_CLICK, 0, 0) |
| 118 | + logger.info("Dismissed Noita crash dialog: clicked '{}' (hwnd: {})", child_title, child_hwnd) |
| 119 | + clicked = True |
| 120 | + except Exception as e: |
| 121 | + logger.debug("Failed to click dialog button: {}", e) |
| 122 | + return True |
| 123 | + |
| 124 | + def _enum_windows_cb(hwnd: int, _lparam) -> bool: |
| 125 | + if not win32gui.IsWindowVisible(hwnd): |
| 126 | + return True |
| 127 | + |
| 128 | + try: |
| 129 | + _, pid = win32process.GetWindowThreadProcessId(hwnd) |
| 130 | + except Exception: |
| 131 | + return True |
| 132 | + |
| 133 | + if target_pid is not None and pid != target_pid: |
| 134 | + # If target PID is provided, only look at dialogs owned by it |
| 135 | + return True |
| 136 | + |
| 137 | + title = win32gui.GetWindowText(hwnd) or "" |
| 138 | + title_lower = title.lower() |
| 139 | + |
| 140 | + # Check if this might be an error dialog (usually they have 'Noita' or 'Error' in title, |
| 141 | + # but their class is often '#32770' for standard dialogs). |
| 142 | + class_name = win32gui.GetClassName(hwnd) |
| 143 | + if "noita" in title_lower or "error" in title_lower or class_name == "#32770": |
| 144 | + try: |
| 145 | + win32gui.EnumChildWindows(hwnd, _enum_child_cb, None) |
| 146 | + except Exception: |
| 147 | + pass |
| 148 | + return True |
| 149 | + |
| 150 | + try: |
| 151 | + win32gui.EnumWindows(_enum_windows_cb, None) |
| 152 | + except Exception as exc: |
| 153 | + logger.debug("EnumWindows failed during dialog check: {}", exc) |
| 154 | + |
| 155 | + return clicked |
| 156 | + |
91 | 157 | def _find_any_noita_hwnd() -> Optional[int]: |
92 | 158 | """Fallback: any visible window with 'Noita' in the title (single-instance mode).""" |
93 | 159 | found: list[int] = [] |
@@ -547,7 +613,9 @@ def _wait_for_new_frame(self, prev_frame: int, timeout: float = 2.0) -> Optional |
547 | 613 | if s is not None and s.get("frame", -1) != prev_frame: |
548 | 614 | return s |
549 | 615 | time.sleep(0.008) # poll every 8 ms (~2x per Noita frame at 60fps) |
550 | | - # timeout — return whatever we have (Noita may be loading/paused) |
| 616 | + |
| 617 | + # timeout — return whatever we have (Noita may be loading/paused/crashed) |
| 618 | + _dismiss_error_dialog(self.noita_pid) |
551 | 619 | return self._get_state() |
552 | 620 |
|
553 | 621 | def step(self, action: int): |
|
0 commit comments