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 PyMemoryEditor/app/main_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
7 changes: 7 additions & 0 deletions PyMemoryEditor/app/results_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
56 changes: 56 additions & 0 deletions PyMemoryEditor/app/scan_types.py
Original file line number Diff line number Diff line change
@@ -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)
59 changes: 51 additions & 8 deletions PyMemoryEditor/app/scan_worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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))
Expand All @@ -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:
Expand Down
60 changes: 60 additions & 0 deletions PyMemoryEditor/app/scanner_panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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),
)


Expand Down Expand Up @@ -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()
Expand All @@ -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:
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -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)
Expand Down
18 changes: 0 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading