diff --git a/PyMemoryEditor/app/main_window.py b/PyMemoryEditor/app/main_window.py index 12aaeab..d645dcd 100644 --- a/PyMemoryEditor/app/main_window.py +++ b/PyMemoryEditor/app/main_window.py @@ -399,6 +399,8 @@ def _on_next_scan(self, request: ScanRequest) -> None: request, self._results_model.all_addresses(), filter_only=True, + # Baseline for the Increased/Decreased/Changed/Unchanged comparisons. + previous_values=self._results_model.value_map(), parent=self, ) worker.chunk_ready.connect( diff --git a/PyMemoryEditor/app/results_view.py b/PyMemoryEditor/app/results_view.py index 5d75a10..3aa01bc 100644 --- a/PyMemoryEditor/app/results_view.py +++ b/PyMemoryEditor/app/results_view.py @@ -173,6 +173,13 @@ def value_at(self, row: int) -> Any: def all_addresses(self) -> List[int]: return list(self._addresses) + def value_map(self) -> Dict[int, Any]: + """ + Snapshot of {address: current_value}, used as the baseline for the + Increased/Decreased/Changed/Unchanged "Next Scan" comparisons. + """ + return {addr: self._values[i] for i, addr in enumerate(self._addresses)} + def count(self) -> int: return len(self._addresses) diff --git a/PyMemoryEditor/app/scan_types.py b/PyMemoryEditor/app/scan_types.py new file mode 100644 index 0000000..54b6f0e --- /dev/null +++ b/PyMemoryEditor/app/scan_types.py @@ -0,0 +1,56 @@ +# -*- coding: utf-8 -*- +""" +App-only "Next Scan" comparison types (Cheat Engine's Increased / Decreased / +Changed / Unchanged / *_BY). + +These deliberately live in the app rather than in the PyMemoryEditor library: +unlike :class:`~PyMemoryEditor.ScanTypesEnum`, they don't map to a single +full-memory search — they compare each address's *current* value against the +value recorded by the *previous* scan. Only the GUI (which already keeps every +found address's last-read value) has that previous value, so the comparison is +pure app logic layered on top of ``search_by_addresses``. +""" +from enum import Enum +from typing import Any, Union + +from PyMemoryEditor import ScanTypesEnum + + +class NextScanType(Enum): + """Refine-only comparisons against the previously recorded value.""" + + INCREASED_VALUE = "increased_value" # current > previous + INCREASED_VALUE_BY = "increased_value_by" # current == previous + target + DECREASED_VALUE = "decreased_value" # current < previous + DECREASED_VALUE_BY = "decreased_value_by" # current == previous - target + CHANGED_VALUE = "changed_value" # current != previous + UNCHANGED_VALUE = "unchanged_value" # current == previous + + +# A scan type in the app may be either a library comparison or an app-only one. +ScanType = Union[ScanTypesEnum, NextScanType] + +# Next-scan-only comparisons that take no user-supplied value (the comparison +# is purely current-vs-previous). The *_BY variants are excluded — they still +# read a delta from the Value field. +NO_VALUE_SCAN_TYPES = frozenset( + { + NextScanType.INCREASED_VALUE, + NextScanType.DECREASED_VALUE, + NextScanType.CHANGED_VALUE, + NextScanType.UNCHANGED_VALUE, + } +) + +# *_BY variants: the Value field carries the amount the value changed by. +DELTA_SCAN_TYPES = frozenset( + { + NextScanType.INCREASED_VALUE_BY, + NextScanType.DECREASED_VALUE_BY, + } +) + + +def is_next_scan_type(scan_type: Any) -> bool: + """True if ``scan_type`` is one of the app-only refine comparisons.""" + return isinstance(scan_type, NextScanType) diff --git a/PyMemoryEditor/app/scan_worker.py b/PyMemoryEditor/app/scan_worker.py index 73be44b..2b9c11b 100644 --- a/PyMemoryEditor/app/scan_worker.py +++ b/PyMemoryEditor/app/scan_worker.py @@ -16,19 +16,21 @@ """ import logging from dataclasses import dataclass -from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple, cast +from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple, cast from PySide6.QtCore import QThread, Signal from PyMemoryEditor import AbstractProcess, ScanTypesEnum +from .scan_types import NextScanType, ScanType from .value_types import ValueTypeSpec _LOG = logging.getLogger(__name__) -# Map of ScanTypesEnum → comparison used by the refine step. +# Map of ScanTypesEnum → comparison used by the refine step. These compare the +# freshly-read value (cur) against the user-supplied target (exp). COMPARATORS = { ScanTypesEnum.EXACT_VALUE: lambda cur, exp: cur == exp, ScanTypesEnum.NOT_EXACT_VALUE: lambda cur, exp: cur != exp, @@ -40,6 +42,19 @@ ScanTypesEnum.NOT_VALUE_BETWEEN: lambda cur, exp: cur < exp[0] or cur > exp[1], } +# Cheat Engine's "Next Scan" comparisons (app-only — see scan_types.py). These +# compare the freshly-read value (cur) against the value recorded at that +# address by the previous scan (prev). ``exp`` carries the delta for the *_BY +# variants and is ignored otherwise. +PREVIOUS_COMPARATORS = { + NextScanType.INCREASED_VALUE: lambda cur, prev, exp: cur > prev, + NextScanType.INCREASED_VALUE_BY: lambda cur, prev, exp: cur == prev + exp, + NextScanType.DECREASED_VALUE: lambda cur, prev, exp: cur < prev, + NextScanType.DECREASED_VALUE_BY: lambda cur, prev, exp: cur == prev - exp, + NextScanType.CHANGED_VALUE: lambda cur, prev, exp: cur != prev, + NextScanType.UNCHANGED_VALUE: lambda cur, prev, exp: cur == prev, +} + # Refresh the UI at most every N matches during a scan. UI_REFRESH_STEP = 750 @@ -50,7 +65,7 @@ class ScanRequest: spec: ValueTypeSpec length: int - scan_type: ScanTypesEnum + scan_type: ScanType # ScanTypesEnum, or app-only NextScanType for refines value: Any # parsed primary value, or (a, b) for ranges writeable_only: bool = False # Optional cached snapshot of memory regions, reused across scans to skip @@ -172,16 +187,21 @@ def __init__( addresses: Sequence[int], *, filter_only: bool = True, + previous_values: Optional[Mapping[int, Any]] = None, parent=None, ): super().__init__(process, parent) self._request = request self._addresses = list(addresses) self._filter_only = filter_only + # Snapshot of {address: value} from the previous scan, needed by the + # Increased/Decreased/Changed/Unchanged comparisons. + self._previous_values: Mapping[int, Any] = previous_values or {} def run(self) -> None: req = self._request compare = COMPARATORS.get(req.scan_type) + prev_compare = PREVIOUS_COMPARATORS.get(req.scan_type) try: generator = self._process.search_by_addresses( @@ -209,6 +229,31 @@ def run(self) -> None: # unreadable page (which on macOS can be most of the heap). if current is None: chunk.append((address, None, False)) + continue + + keeps = True + if self._filter_only and prev_compare is not None: + # Increased/Decreased/Changed/Unchanged: compare against the + # value recorded at this address by the previous scan. With + # no baseline (address discovered without a value), keep it + # rather than guessing. + previous = self._previous_values.get(address) + try: + keeps = previous is not None and bool( + prev_compare(current, previous, req.value) + ) + except TypeError as exc: + _LOG.debug( + "refine comparator raised TypeError at 0x%X " + "(scan_type=%s, current=%r, previous=%r, target=%r): %s", + address, + req.scan_type, + current, + previous, + req.value, + exc, + ) + keeps = False elif self._filter_only and compare is not None: try: keeps = bool(compare(current, req.value)) @@ -227,11 +272,9 @@ def run(self) -> None: exc, ) keeps = False - chunk.append((address, current, keeps)) - if keeps: - kept += 1 - else: - chunk.append((address, current, True)) + + chunk.append((address, current, keeps)) + if keeps: kept += 1 if len(chunk) >= UI_REFRESH_STEP: diff --git a/PyMemoryEditor/app/scanner_panel.py b/PyMemoryEditor/app/scanner_panel.py index fd7d0cf..610e37d 100644 --- a/PyMemoryEditor/app/scanner_panel.py +++ b/PyMemoryEditor/app/scanner_panel.py @@ -37,6 +37,12 @@ from PyMemoryEditor import ScanTypesEnum +from .scan_types import ( + DELTA_SCAN_TYPES, + NO_VALUE_SCAN_TYPES, + NextScanType, + is_next_scan_type, +) from .scan_worker import ScanRequest from .value_types import VALUE_TYPES, find_spec, parse_value @@ -50,6 +56,13 @@ ("Smaller Than or Equal To", ScanTypesEnum.SMALLER_THAN_OR_EXACT_VALUE), ("Value Between", ScanTypesEnum.VALUE_BETWEEN), ("Not Value Between", ScanTypesEnum.NOT_VALUE_BETWEEN), + # App-only "Next Scan" comparisons (current value vs. previous scan). + ("Increased Value", NextScanType.INCREASED_VALUE), + ("Increased Value By", NextScanType.INCREASED_VALUE_BY), + ("Decreased Value", NextScanType.DECREASED_VALUE), + ("Decreased Value By", NextScanType.DECREASED_VALUE_BY), + ("Changed Value", NextScanType.CHANGED_VALUE), + ("Unchanged Value", NextScanType.UNCHANGED_VALUE), ) @@ -255,6 +268,11 @@ def _on_type_changed(self, label: str) -> None: self._second_value_label.hide() self._scan_combo.setEnabled(not is_pattern and not self._busy) + # Re-apply the value-field state for the current scan type now that the + # value shape changed (e.g. keep the Value field disabled for a + # no-value comparison, restore its placeholder otherwise). + self._on_scan_type_changed(self._scan_combo.currentIndex()) + # The pattern/non-pattern flag also drives Next-Scan availability, so # let _refresh_buttons re-evaluate now that the type has flipped. self._refresh_buttons() @@ -268,6 +286,25 @@ def _on_scan_type_changed(self, index: int) -> None: self._second_value_edit.setVisible(ranged) self._second_value_label.setVisible(ranged) + # In pattern mode the Value field holds the AOB pattern and the + # scan-type combo is forced to EXACT, so leave its value field alone. + spec = find_spec(self._type_combo.currentText()) + if spec is not None and spec.is_pattern: + return + + # Increased/Decreased/Changed/Unchanged compare against the previous + # scan and take no target value, so disable the Value field. The *_BY + # variants keep it enabled to read the delta. + no_value = scan_type in NO_VALUE_SCAN_TYPES + self._value_edit.setEnabled(not no_value) + if no_value: + self._value_edit.clear() + self._value_edit.setPlaceholderText("(not used for this scan type)") + elif scan_type in DELTA_SCAN_TYPES: + self._value_edit.setPlaceholderText("Amount the value changed by") + else: + self._value_edit.setPlaceholderText("e.g. 100 or 0x64 or Hello") + def _build_request(self, *, with_value: bool = True) -> Optional[ScanRequest]: spec = find_spec(self._type_combo.currentText()) if spec is None: @@ -296,6 +333,18 @@ def _build_request(self, *, with_value: bool = True) -> Optional[ScanRequest]: self._length_spin.value() if spec.accepts_length_override else None ) + # Increased/Decreased/Changed/Unchanged compare current vs previous and + # need no target value — just the value shape (type + length). + if scan_type in NO_VALUE_SCAN_TYPES: + length = length_override if length_override is not None else spec.length + return ScanRequest( + spec=spec, + length=int(length), + scan_type=scan_type, + value=None, + writeable_only=self._writable_check.isChecked(), + ) + value: Any try: if scan_type in ( @@ -328,6 +377,17 @@ def _build_request(self, *, with_value: bool = True) -> Optional[ScanRequest]: ) def _on_first_scan(self) -> None: + _, scan_type = SCAN_TYPE_CHOICES[self._scan_combo.currentIndex()] + if is_next_scan_type(scan_type): + QMessageBox.information( + self, + "First Scan", + "Increased / Decreased / Changed / Unchanged compare against a " + "previous scan, so they only work as a Next Scan. Run a First " + "Scan with another comparison (e.g. Exact Value) first, then " + "switch to one of these and press Next Scan.", + ) + return request = self._build_request() if request is not None: self.first_scan_requested.emit(request) diff --git a/README.md b/README.md index e9aafc7..914c5ce 100644 --- a/README.md +++ b/README.md @@ -44,24 +44,6 @@ reading, writing and searching values in the process memory. --- -## ✨ Highlights - -| | | -| --- | --- | -| **Read & write memory** | Change live values on the fly — just like Cheat Engine, but in a few lines of Python. | -| **Pure-Python via `ctypes`** | No compilation, no native wheels — `pip install` and you're done. | -| **Scan modes** | Exact, not-exact, bigger / smaller (±equal), in-range, out-of-range. | -| **Pattern scan** | Byte signatures or regex — `grep` for process memory. | -| **Pointer chains** | Walk multi-level pointers (`[[base+0x10]+0x20]+0x30`) in one call. | -| **Pointer scan** *(reverse)* | *Find* the static pointer path to a moving address — then narrow it down across restarts, Cheat-Engine style. | -| **Live pointers** | A `RemotePointer` handle re-resolves its chain on every `.value` read/write. | -| **Module enumeration** | List loaded executables & libraries with their base address — `base + offset` beats ASLR. | -| **Allocate / free memory** | Reserve and release memory inside the target (Windows & macOS). | -| **Snapshot caching** | The Cheat-Engine "scan → refine → refine" loop, accelerated. | -| **Bundled GUI app** | A full memory scanner ships in the box — just type `pymemoryeditor`. | - ---- - ## Installation Available on PyPI for Windows, Linux and macOS — no native build step, no extra wheels. diff --git a/tests/test_refine_scan_worker.py b/tests/test_refine_scan_worker.py new file mode 100644 index 0000000..3d3b054 --- /dev/null +++ b/tests/test_refine_scan_worker.py @@ -0,0 +1,127 @@ +# -*- coding: utf-8 -*- + +""" +Functional tests for ``RefineScanWorker`` — Cheat Engine's "Next Scan". + +These focus on the comparison block, including the app-only Increased / +Decreased / Changed / Unchanged / *_BY types that compare the freshly-read +value against the value recorded by the previous scan. ``run`` is a ``QThread`` +method but we call it directly (no thread, no event loop) and collect the +``chunk_ready`` signal — the same pattern as ``test_cheat_poll_worker``. +""" + +import os + +import pytest + + +pytest.importorskip( + "PySide6", reason="App tests require PySide6 (install with [app] extra)." +) + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from PyMemoryEditor.app.scan_types import NextScanType # noqa: E402 + + +@pytest.fixture(scope="module") +def qapp(): + from PySide6.QtWidgets import QApplication + + app = QApplication.instance() or QApplication([]) + yield app + + +class _FakeProcess: + """Returns the configured current value for each requested address.""" + + def __init__(self, current): + self._current = current # {address: value} + + def search_by_addresses(self, pytype, length, addresses, *, memory_regions=None): + for addr in addresses: + yield addr, self._current.get(addr) + + +def _spec(): + from PyMemoryEditor.app.value_types import VALUE_TYPES + + return VALUE_TYPES[0] # 4 Bytes (Int32) + + +def _run(process, scan_type, value, previous, current): + """Run a refine pass and return {address: keeps} for the readable rows.""" + from PyMemoryEditor.app.scan_worker import RefineScanWorker, ScanRequest + + request = ScanRequest( + spec=_spec(), + length=4, + scan_type=scan_type, + value=value, + ) + worker = RefineScanWorker( + process, + request, + list(previous.keys()), + filter_only=True, + previous_values=previous, + ) + + collected = {} + + def collect(chunk): + for address, _current, keeps in chunk: + collected[address] = keeps + + worker.chunk_ready.connect(collect) + worker.run() + return collected + + +@pytest.mark.parametrize( + "scan_type, value, expected_kept", + [ + (NextScanType.INCREASED_VALUE, None, {0x10, 0x30}), # 10→11, 30→31 up + (NextScanType.DECREASED_VALUE, None, {0x20}), # 20→5 down + (NextScanType.CHANGED_VALUE, None, {0x10, 0x20, 0x30}), + (NextScanType.UNCHANGED_VALUE, None, {0x40}), # 40 stayed 8 + (NextScanType.INCREASED_VALUE_BY, 1, {0x10, 0x30}), # both rose by exactly 1 + (NextScanType.DECREASED_VALUE_BY, 15, {0x20}), # 20 → 5 is -15 + ], +) +def test_previous_value_comparisons(qapp, scan_type, value, expected_kept): + previous = {0x10: 10, 0x20: 20, 0x30: 30, 0x40: 8} + current = {0x10: 11, 0x20: 5, 0x30: 31, 0x40: 8} + process = _FakeProcess(current) + + collected = _run(process, scan_type, value, previous, current) + kept = {addr for addr, keeps in collected.items() if keeps} + assert kept == expected_kept + + +def test_increased_value_by_rejects_wrong_delta(qapp): + previous = {0x10: 10} + current = {0x10: 13} # rose by 3, not by 1 + process = _FakeProcess(current) + + collected = _run(process, NextScanType.INCREASED_VALUE_BY, 1, previous, current) + assert collected == {0x10: False} + + +def test_missing_baseline_is_dropped(qapp): + # Address has no recorded previous value → nothing to compare, so drop it. + previous = {0x10: None} + current = {0x10: 99} + process = _FakeProcess(current) + + collected = _run(process, NextScanType.INCREASED_VALUE, None, previous, current) + assert collected == {0x10: False} + + +def test_unreadable_address_is_dropped(qapp): + previous = {0x10: 10} + current = {0x10: None} # dead/unreadable page + process = _FakeProcess(current) + + collected = _run(process, NextScanType.CHANGED_VALUE, None, previous, current) + assert collected == {0x10: False}