diff --git a/init.lua b/init.lua index c08070b..5154273 100644 --- a/init.lua +++ b/init.lua @@ -372,11 +372,13 @@ local function apply_action(player, action) -- Fire: Fix "eternal first frame" bug by using local agent_was_firing cset(ctrl, "mButtonDownFire", do_fire) cset(ctrl, "mButtonDownLeftClick", do_fire) + cset(ctrl, "mButtonDownAction", do_fire) if do_fire and not agent_was_firing then local frame = GameGetFrameNum() cset(ctrl, "mButtonFrameFire", frame) cset(ctrl, "mButtonFrameLeftClick", frame) + cset(ctrl, "mButtonFrameAction", frame) agent_was_firing = true elseif not do_fire then agent_was_firing = false diff --git a/noita_env.py b/noita_env.py index 47aa1c1..be4ccbd 100644 --- a/noita_env.py +++ b/noita_env.py @@ -46,8 +46,14 @@ # still works. Each env instance owns its own mss handle (mss is not thread-safe). import cv2 import mss -import win32gui -import win32process +try: + import win32gui +except ImportError: + win32gui = None +try: + import win32process +except ImportError: + win32process = None def _capture_noita_frame() -> "Optional[Image.Image]": @@ -88,6 +94,66 @@ def _cb(hwnd: int, _lparam) -> bool: return found[0] if found else None + +def _dismiss_error_dialog(target_pid: Optional[int] = None) -> bool: + """ + Searches for Noita error/crash dialogs and clicks the 'Always Ignore' + button if present to prevent the training from hanging. + """ + BM_CLICK = 0x00F5 + clicked = False + + def _enum_child_cb(child_hwnd: int, _lparam) -> bool: + nonlocal clicked + if not win32gui.IsWindowVisible(child_hwnd): + return True + child_title = win32gui.GetWindowText(child_hwnd) or "" + child_title_lower = child_title.lower() + if "ignore" in child_title_lower and "always" in child_title_lower: + try: + # SendMessage is blocking, PostMessage is async + import win32con + import win32api + win32api.PostMessage(child_hwnd, BM_CLICK, 0, 0) + logger.info("Dismissed Noita crash dialog: clicked '{}' (hwnd: {})", child_title, child_hwnd) + clicked = True + except Exception as e: + logger.debug("Failed to click dialog button: {}", e) + return True + + def _enum_windows_cb(hwnd: int, _lparam) -> bool: + if not win32gui.IsWindowVisible(hwnd): + return True + + try: + _, pid = win32process.GetWindowThreadProcessId(hwnd) + except Exception: + return True + + if target_pid is not None and pid != target_pid: + # If target PID is provided, only look at dialogs owned by it + return True + + title = win32gui.GetWindowText(hwnd) or "" + title_lower = title.lower() + + # Check if this might be an error dialog (usually they have 'Noita' or 'Error' in title, + # but their class is often '#32770' for standard dialogs). + class_name = win32gui.GetClassName(hwnd) + if "noita" in title_lower or "error" in title_lower or class_name == "#32770": + try: + win32gui.EnumChildWindows(hwnd, _enum_child_cb, None) + except Exception: + pass + return True + + try: + win32gui.EnumWindows(_enum_windows_cb, None) + except Exception as exc: + logger.debug("EnumWindows failed during dialog check: {}", exc) + + return clicked + def _find_any_noita_hwnd() -> Optional[int]: """Fallback: any visible window with 'Noita' in the title (single-instance mode).""" found: list[int] = [] @@ -547,7 +613,9 @@ def _wait_for_new_frame(self, prev_frame: int, timeout: float = 2.0) -> Optional if s is not None and s.get("frame", -1) != prev_frame: return s time.sleep(0.008) # poll every 8 ms (~2x per Noita frame at 60fps) - # timeout — return whatever we have (Noita may be loading/paused) + + # timeout — return whatever we have (Noita may be loading/paused/crashed) + _dismiss_error_dialog(self.noita_pid) return self._get_state() def step(self, action: int): diff --git a/patch_req.py b/patch_req.py new file mode 100644 index 0000000..cdcb405 --- /dev/null +++ b/patch_req.py @@ -0,0 +1,20 @@ +with open("requirements.txt", "r") as f: + content = f.read() + +import re +content = re.sub(r'<<<<<<< HEAD.*?=======\n', '', content, flags=re.DOTALL) +content = content.replace('>>>>>>> origin/main\n', '') + +# add missing test reqs +content += """ +# Development +black>=23.0.0 +flake8>=6.0.0 +mypy>=1.5.0 +pytest>=7.4.0 +pytest-cov>=4.1.0 +pygetwindow +""" + +with open("requirements.txt", "w") as f: + f.write(content) diff --git a/requirements.txt b/requirements.txt index 8d79bfd..3327d7e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -30,3 +30,11 @@ websockets>=12.0 # Screenshots & Image processing mss>=9.0.0 pillow>=10.0.0 + +# Development +black>=23.0.0 +flake8>=6.0.0 +mypy>=1.5.0 +pytest>=7.4.0 +pytest-cov>=4.1.0 +pygetwindow diff --git a/tests/test_config.py b/tests/test_config.py index 3798055..ae09054 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -44,7 +44,7 @@ def test_default_gamma(self): assert cfg().gamma == pytest.approx(0.99) def test_default_ent_coef(self): - assert cfg().ent_coef == pytest.approx(0.015) + assert cfg().ent_coef == pytest.approx(0.03) def test_default_checkpoint_dir(self): assert cfg().checkpoint_dir == "./checkpoints" diff --git a/tests/test_obs.py b/tests/test_obs.py index df920f3..c6a9540 100644 --- a/tests/test_obs.py +++ b/tests/test_obs.py @@ -18,7 +18,7 @@ def make_env(): import importlib, types, sys # Stub out hardware-dependent top-level imports that run at module load - for mod_name in ("mss", "pygetwindow"): + for mod_name in ("mss", "pygetwindow", "win32gui"): if mod_name not in sys.modules: sys.modules[mod_name] = types.ModuleType(mod_name) diff --git a/tests/test_video_recorder.py b/tests/test_video_recorder.py index 62a5a08..0825c62 100644 --- a/tests/test_video_recorder.py +++ b/tests/test_video_recorder.py @@ -186,7 +186,7 @@ def test_zero_cooldown_allows_rapid_triggers(self, recorder): class TestWindowDetection: def test_find_returns_none_when_no_noita(self): - result = VideoRecorder._find_noita_window() + result = VideoRecorder._find_noita_hwnd() # pygetwindow is stubbed to return [] assert result is None