diff --git a/README.md b/README.md index f602dea..fcbfb9b 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,12 @@ Click the green **Code** button on this GitHub page → **Download ZIP**. Extrac **Step 3 — Install dependencies** Double-click **`install.bat`** inside the extracted folder. It will create the `botty` conda environment and install everything. This takes a few minutes the first time. +`install.bat` detects Windows 10 vs Windows 11 automatically: +- Windows 10 uses `requirements-win10.txt` and absolute mouse input. +- Windows 11 uses `requirements-win11.txt` and relative mouse input. + +Botty also runs OS detection at startup and logs the selected requirements profile and mouse mode. + **Step 4 — Configure** Open `config\params.ini` in Notepad and set at minimum: - `[char] type=` — your build (`sorceress`, `hammerdin`, `paladin`, `trapsin`, …) diff --git a/development.md b/development.md index d6d8380..53c9237 100644 --- a/development.md +++ b/development.md @@ -13,11 +13,23 @@ cd botty # Create the conda environment (installs all Python deps + tesserocr with tesseract) conda env create -f environment.yml -# Activate and run +# Activate conda activate botty + +# Optional pip profile after the conda env exists: +# Windows 10: +python -m pip install -r requirements-win10.txt +# Windows 11: +python -m pip install -r requirements-win11.txt + +# Run python src/main.py ``` +`install.bat` detects Windows 10 vs Windows 11 and installs the matching +requirements profile automatically. Botty runtime detection lives in +`src/utils/os_detect.py`; startup logs show the selected profile and mouse mode. + ### PowerShell users ```powershell conda init powershell # One time setup diff --git a/install.bat b/install.bat index d31d837..c76756a 100644 --- a/install.bat +++ b/install.bat @@ -20,10 +20,14 @@ set /a "WIN_BUILD_NUM=%WIN_BUILD%" 2>nul || set "WIN_BUILD_NUM=0" if %WIN_BUILD_NUM% GEQ 22000 ( echo Detected Windows 11 — using relative mouse mode (no ABSOLUTE flag) set "WIN11=1" + set "REQ_FILE=requirements-win11.txt" ) else ( echo Detected Windows 10 or earlier — using absolute mouse mode set "WIN11=0" + set "REQ_FILE=requirements-win10.txt" ) +if not exist "%REQ_FILE%" set "REQ_FILE=requirements.txt" +echo Requirements profile: %REQ_FILE% echo. :: --- Find conda --- @@ -108,6 +112,21 @@ exit /b 1 :env_created echo Botty Python: %PYTHON% +:: --- Install OS-specific pip requirements profile --- +echo. +echo Installing Botty requirements from %REQ_FILE%... +"%CONDA_EXE%" run -n botty python -m pip install -r "%REQ_FILE%" +if %errorlevel% neq 0 ( + echo. + echo ERROR: pip requirements install failed for %REQ_FILE%. + pause + exit /b 1 +) + +echo. +echo Botty osdetect: +"%CONDA_EXE%" run -n botty python -c "import sys; sys.path.insert(0, 'src'); from utils.os_detect import describe_current_os; print(describe_current_os())" + :: --- Remove old pip-installed tesserocr wheel if present (we may reinstall from bundled wheel below) --- echo. echo Removing old pip-installed tesserocr if present... diff --git a/requirements-win10.txt b/requirements-win10.txt new file mode 100644 index 0000000..9603aee --- /dev/null +++ b/requirements-win10.txt @@ -0,0 +1,5 @@ +# Botty dependency profile for Windows 10. +# +# Windows 10 uses Botty's absolute SendInput mouse mode. +# Keep package versions aligned with requirements.txt and environment.yml. +-r requirements.txt diff --git a/requirements-win11.txt b/requirements-win11.txt new file mode 100644 index 0000000..b7a333a --- /dev/null +++ b/requirements-win11.txt @@ -0,0 +1,6 @@ +# Botty dependency profile for Windows 11. +# +# Windows 11 uses Botty's relative SendInput mouse mode because absolute +# SendInput movement is unreliable on recent Windows 11 builds. +# Keep package versions aligned with requirements.txt and environment.yml. +-r requirements.txt diff --git a/src/input_layer/win_input.py b/src/input_layer/win_input.py index d7a2215..b773f73 100644 --- a/src/input_layer/win_input.py +++ b/src/input_layer/win_input.py @@ -5,26 +5,17 @@ No kernel drivers, no third-party DLLs. Only standard system DLLs (user32, kerne import ctypes import time import struct -import platform from ctypes import wintypes from utils.misc import wait +from utils.os_detect import detect_os -# ─── Win10 vs Win11 mouse mode detection ─── +# Win10 vs Win11 mouse mode detection # Win10: SendInput needs MOUSEEVENTF_ABSOLUTE for correct positioning # Win11 (25H2+): ABSOLUTE flag breaks mouse — use relative-only # Detect at import time so the whole session uses the right mode. -def _is_win11(): - """Return True if running on Windows 11 (build >= 22000).""" - try: - ver = platform.win32_ver() # ('10', '10.0.26200', 'SP0', ...) - build_str = ver[1].split('.')[2] # '26200' - build = int(build_str) - return build >= 22000 - except Exception: - return False - -_USE_ABSOLUTE_MOUSE = not _is_win11() # True for Win10, False for Win11 +_OS_INFO = detect_os() +_USE_ABSOLUTE_MOUSE = _OS_INFO.use_absolute_mouse # ─── user32 constants ─── @@ -196,7 +187,7 @@ def _make_mouse_input(flags: int, dx: int = 0, dy: int = 0, data: int = 0): i.union.mi.dx = dx i.union.mi.dy = dy i.union.mi.mouseData = data - i.union.mi.dwFlags = flags | (MOUSEEVENTF_ABSOLUTE if _USE_ABSOLUTE_MOUSE else 0) + i.union.mi.dwFlags = flags i.union.mi.dwExtraInfo = None return i @@ -274,12 +265,17 @@ def get_screen_size(): return user32.GetSystemMetrics(0), user32.GetSystemMetrics(1) def mouse_move(x: int, y: int): - """Move mouse to absolute screen position (uses absolute SendInput).""" - screen_w, screen_h = get_screen_size() - # SendInput expects normalized absolute coordinates 0-65535 - norm_x = int(x * 65535 / screen_w) - norm_y = int(y * 65535 / screen_h) - _send_input(_make_mouse_input(MOUSEEVENTF_MOVE | MOUSEEVENTF_ABSOLUTE, norm_x, norm_y)) + """Move mouse to an absolute screen position using the OS-specific mode.""" + if _USE_ABSOLUTE_MOUSE: + screen_w, screen_h = get_screen_size() + # SendInput expects normalized absolute coordinates 0-65535. + norm_x = int(x * 65535 / screen_w) + norm_y = int(y * 65535 / screen_h) + _send_input(_make_mouse_input(MOUSEEVENTF_MOVE | MOUSEEVENTF_ABSOLUTE, norm_x, norm_y)) + return + + cur_x, cur_y = get_cursor_pos() + _send_input(_make_mouse_input(MOUSEEVENTF_MOVE, int(x - cur_x), int(y - cur_y))) def mouse_down(button: str = "left"): """Press mouse button down.""" diff --git a/src/main.py b/src/main.py index fb0a3d5..89acc02 100644 --- a/src/main.py +++ b/src/main.py @@ -24,6 +24,7 @@ from game_controller import GameController from utils.graphic_debugger import GraphicDebuggerController from utils.misc import restore_d2r_window_visibility from utils.auto_settings import adjust_settings, backup_settings, restore_settings_from_backup +from utils.os_detect import detect_os from ui.run_selector import open_run_selector @dataclass @@ -66,18 +67,15 @@ def on_exit(controllers: Controllers): def _log_platform_info(): """Log Windows version, mouse mode, and OCR backend at startup.""" - import platform - ver = platform.win32_ver() - try: - build = int(ver[1].split('.')[2]) - except Exception: - build = 0 - is_win11 = build >= 22000 - win_label = f"Windows 11 (build {build})" if is_win11 else f"Windows 10 (build {build})" - Logger.info(f"Platform: {win_label}") + os_info = detect_os() + build = f" (build {os_info.build})" if os_info.build else "" + Logger.info(f"Platform: {os_info.label}{build}") + if not os_info.supported: + Logger.warning(f"Unsupported OS detected: {os_info.system} {os_info.release}") + Logger.info(f"Requirements profile: {os_info.requirements_file}") from input_layer.win_input import _USE_ABSOLUTE_MOUSE - mouse_mode = "relative (Win11)" if not _USE_ABSOLUTE_MOUSE else "absolute (Win10)" + mouse_mode = "absolute (Windows 10)" if _USE_ABSOLUTE_MOUSE else "relative (Windows 11)" Logger.info(f"Mouse input mode: {mouse_mode}") # OCR backend check diff --git a/src/utils/os_detect.py b/src/utils/os_detect.py new file mode 100644 index 0000000..feeea86 --- /dev/null +++ b/src/utils/os_detect.py @@ -0,0 +1,110 @@ +"""Operating-system detection for Botty runtime and installers. + +Windows reports Windows 11 as major version 10, so Botty must classify the OS +from the Windows build number instead of the marketing version string. +""" +from __future__ import annotations + +from dataclasses import dataclass +import os +import platform +import sys + + +WINDOWS_11_MIN_BUILD = 22000 + + +@dataclass(frozen=True) +class BottyOsInfo: + system: str + release: str + version: str + build: int + label: str + requirements_file: str + use_absolute_mouse: bool + supported: bool + + @property + def mouse_mode(self) -> str: + return "absolute" if self.use_absolute_mouse else "relative" + + +def parse_windows_build(version: str | None) -> int: + """Extract a Windows build number from strings like '10.0.22631'.""" + if not version: + return 0 + parts = str(version).split(".") + for part in reversed(parts): + try: + return int(part) + except ValueError: + continue + return 0 + + +def classify_windows(build: int) -> tuple[str, str, bool, bool]: + """Return label, requirements file, absolute-mouse flag, supported flag.""" + if build >= WINDOWS_11_MIN_BUILD: + return "Windows 11", "requirements-win11.txt", False, True + if build > 0: + return "Windows 10", "requirements-win10.txt", True, True + return "Windows unknown", "requirements-win10.txt", True, False + + +def _registry_windows_build() -> int: + if sys.platform != "win32": + return 0 + try: + import winreg + + with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Microsoft\Windows NT\CurrentVersion") as key: + value, _ = winreg.QueryValueEx(key, "CurrentBuild") + return int(value) + except Exception: + return 0 + + +def detect_os() -> BottyOsInfo: + system = platform.system() + release = platform.release() + version = platform.version() + + if system == "Windows" or os.name == "nt": + build = parse_windows_build(platform.win32_ver()[1]) or parse_windows_build(version) or _registry_windows_build() + label, req_file, use_absolute, supported = classify_windows(build) + return BottyOsInfo( + system="Windows", + release=release, + version=version, + build=build, + label=label, + requirements_file=req_file, + use_absolute_mouse=use_absolute, + supported=supported, + ) + + return BottyOsInfo( + system=system or "unknown", + release=release, + version=version, + build=0, + label=system or "unknown", + requirements_file="requirements.txt", + use_absolute_mouse=True, + supported=False, + ) + + +def describe_current_os() -> str: + info = detect_os() + build = f" build {info.build}" if info.build else "" + support = "supported" if info.supported else "unsupported" + return ( + f"{info.label}{build} ({support}); " + f"requirements={info.requirements_file}; mouse={info.mouse_mode}" + ) + + +if __name__ == "__main__": + print(describe_current_os()) diff --git a/test/utils/os_detect_test.py b/test/utils/os_detect_test.py new file mode 100644 index 0000000..f40adde --- /dev/null +++ b/test/utils/os_detect_test.py @@ -0,0 +1,28 @@ +from utils.os_detect import classify_windows, parse_windows_build + + +def test_parse_windows_build_from_full_version(): + assert parse_windows_build("10.0.19045") == 19045 + assert parse_windows_build("10.0.22631") == 22631 + + +def test_parse_windows_build_handles_bad_input(): + assert parse_windows_build("") == 0 + assert parse_windows_build(None) == 0 + assert parse_windows_build("not.a.build") == 0 + + +def test_classify_windows_10(): + label, req_file, use_absolute, supported = classify_windows(19045) + assert label == "Windows 10" + assert req_file == "requirements-win10.txt" + assert use_absolute is True + assert supported is True + + +def test_classify_windows_11(): + label, req_file, use_absolute, supported = classify_windows(22631) + assert label == "Windows 11" + assert req_file == "requirements-win11.txt" + assert use_absolute is False + assert supported is True diff --git a/tools/check_dependencies.ps1 b/tools/check_dependencies.ps1 index 27acc41..7a00a13 100644 --- a/tools/check_dependencies.ps1 +++ b/tools/check_dependencies.ps1 @@ -38,12 +38,18 @@ function Get-RequirementPackageNames { if (-not (Test-Path $RequirementsPath)) { return $names } + $requirementsDir = Split-Path $RequirementsPath -Parent foreach ($line in Get-Content $RequirementsPath) { $trim = $line.Trim() if (-not $trim -or $trim.StartsWith("#")) { continue } # Strip inline comments. $trim = ($trim -split "#")[0].Trim() if (-not $trim) { continue } + if ($trim -match "^-r\s+(.+)$" -or $trim -match "^--requirement\s+(.+)$") { + $includePath = Join-Path $requirementsDir $Matches[1].Trim() + $names += Get-RequirementPackageNames -RequirementsPath $includePath + continue + } # Strip environment markers. $trim = ($trim -split ";")[0].Trim() if (-not $trim) { continue } @@ -54,12 +60,39 @@ function Get-RequirementPackageNames { return ($names | Select-Object -Unique) } +function Get-BottyOsProfile { + try { + $build = [int](Get-ItemProperty "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion").CurrentBuild + } catch { + $build = 0 + } + + if ($build -ge 22000) { + return [pscustomobject]@{ + Label = "Windows 11" + Build = $build + RequirementsFile = "requirements-win11.txt" + MouseMode = "relative" + } + } + + return [pscustomobject]@{ + Label = "Windows 10" + Build = $build + RequirementsFile = "requirements-win10.txt" + MouseMode = "absolute" + } +} + $repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") Set-Location $repoRoot Write-Host "=== Botty Dependency Check ===" -ForegroundColor Cyan Write-Host "Repo: $repoRoot" +$osProfile = Get-BottyOsProfile +Write-Check "PASS" "OS profile: $($osProfile.Label) build $($osProfile.Build), requirements=$($osProfile.RequirementsFile), mouse=$($osProfile.MouseMode)" + $pythonExe = $null foreach ($candidate in @("python", "py -3")) { if ($candidate -eq "python") { @@ -142,11 +175,14 @@ if (Test-PythonImport -PythonExe $pythonExe -ModuleName "tesserocr") { $failedImports += "tesserocr" } -# requirements.txt package check -$requirementsPath = Join-Path $repoRoot "requirements.txt" +# requirements package check +$requirementsPath = Join-Path $repoRoot $osProfile.RequirementsFile +if (-not (Test-Path $requirementsPath)) { + $requirementsPath = Join-Path $repoRoot "requirements.txt" +} if (Test-Path $requirementsPath) { Write-Host "" - Write-Host "--- requirements.txt package check ---" -ForegroundColor Cyan + Write-Host "--- $(Split-Path $requirementsPath -Leaf) package check ---" -ForegroundColor Cyan $reqMissing = @() $reqPkgs = Get-RequirementPackageNames -RequirementsPath $requirementsPath foreach ($pkg in $reqPkgs) { @@ -164,7 +200,7 @@ if (Test-Path $requirementsPath) { } if ($reqMissing.Count -gt 0) { Write-Host "Missing requirements packages: $($reqMissing -join ', ')" -ForegroundColor Yellow - Write-Host "Install with: $pythonExe -m pip install -r requirements.txt" -ForegroundColor Yellow + Write-Host "Install with: $pythonExe -m pip install -r $(Split-Path $requirementsPath -Leaf)" -ForegroundColor Yellow $failedImports += ($reqMissing | ForEach-Object { "pip:$($_)" }) } } else { @@ -217,7 +253,7 @@ Write-Host "" Write-Host "Manual fallback (if install.bat fails):" -ForegroundColor Yellow Write-Host " conda create -n botty -y python=3.10" -ForegroundColor Yellow Write-Host " conda activate botty" -ForegroundColor Yellow -Write-Host " python -m pip install -r requirements.txt" -ForegroundColor Yellow +Write-Host " python -m pip install -r $($osProfile.RequirementsFile)" -ForegroundColor Yellow Write-Host " python -m pip install dependencies\tesserocr-2.5.2-cp310-cp310-win_amd64.whl" -ForegroundColor Yellow Write-Host " .\check_dependencies.bat" -ForegroundColor Yellow Write-Host ""