Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Binding mButtonDownAction to the fire command will cause the agent to trigger environmental interactions (such as picking up items, swapping wands, or entering portals) every time it attempts to shoot. While this may be a necessary workaround if the engine requires it for wand usage in this context, it introduces a significant side effect where the agent might unintentionally teleport or replace its equipment during combat. Consider if this binding can be restricted to specific item types or if a dedicated interaction action should be added to the action space to separate combat from interaction.


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
Expand Down
74 changes: 71 additions & 3 deletions noita_env.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]":
Expand Down Expand Up @@ -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] = []
Expand Down Expand Up @@ -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):
Expand Down
20 changes: 20 additions & 0 deletions patch_req.py
Original file line number Diff line number Diff line change
@@ -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)
8 changes: 8 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion tests/test_obs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
2 changes: 1 addition & 1 deletion tests/test_video_recorder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading