diff --git a/PyMemoryEditor/app/cheat_table.py b/PyMemoryEditor/app/cheat_table.py
index 6b821ce..4454d3b 100644
--- a/PyMemoryEditor/app/cheat_table.py
+++ b/PyMemoryEditor/app/cheat_table.py
@@ -207,7 +207,7 @@ def _write_row(self, row: int, entry: CheatEntry) -> None:
check.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled | Qt.ItemIsSelectable)
check.setCheckState(Qt.Checked if entry.frozen else Qt.Unchecked)
check.setTextAlignment(Qt.AlignCenter)
- check.setToolTip("Toggle to freeze the value — Cheat Engine style.")
+ check.setToolTip("Toggle to freeze the value.")
self._table.setItem(row, self.COL_ACTIVE, check)
desc = QTableWidgetItem(entry.description)
diff --git a/PyMemoryEditor/app/main_window.py b/PyMemoryEditor/app/main_window.py
index 7ba2e90..436c339 100644
--- a/PyMemoryEditor/app/main_window.py
+++ b/PyMemoryEditor/app/main_window.py
@@ -483,7 +483,7 @@ def _on_refine_done(self, kept: int) -> None:
def _on_refresh_done(self, _kept: int) -> None:
self._results_label.setText(
- f"{self._results_model.count():,} addresses — values refreshed."
+ f"{self._results_model.count():,} addresses found."
)
self._scanner.set_has_results(self._results_model.count() > 0)
@@ -669,7 +669,7 @@ def _show_about(self) -> None:
self,
"About PyMemoryEditor",
f"PyMemoryEditor v{__version__}
"
- f"App — Cheat Engine-style memory scanner.
"
+ f"PyMemoryEditor App — Cheat Engine-style memory scanner.
"
f"Platform: {sys.platform}
"
f"Target process: PID {self._process.pid} ({self._proc_name})
"
"Source: "
diff --git a/PyMemoryEditor/app/memory_map_dialog.py b/PyMemoryEditor/app/memory_map_dialog.py
index ff41138..1bf9a85 100644
--- a/PyMemoryEditor/app/memory_map_dialog.py
+++ b/PyMemoryEditor/app/memory_map_dialog.py
@@ -6,24 +6,29 @@
protection flags (decoded into a human "R W X" string), shared/private state,
and the backing path on Linux. The toolbar buttons let the user:
-* refresh the snapshot,
* copy a base address,
-* jump straight into the hex viewer at any region.
+* jump straight into the hex viewer at any region,
+* allocate / free memory in the target (Windows & macOS).
-The dialog also publishes its last snapshot so the main window can reuse it
-as the ``memory_regions`` kwarg to subsequent scans.
+The region list auto-refreshes every 1000 ms, so allocations and frees (and any
+other mapping changes in the target) show up without a manual refresh. The
+dialog also publishes its last snapshot so the main window can reuse it as the
+``memory_regions`` kwarg to subsequent scans.
"""
import sys
from typing import Dict, List, Optional
-from PySide6.QtCore import Qt, QThread, Signal
-from PySide6.QtGui import QGuiApplication, QStandardItem, QStandardItemModel
+from PySide6.QtCore import Qt, QThread, QTimer, Signal
+from PySide6.QtGui import QFont, QGuiApplication, QStandardItem, QStandardItemModel
from PySide6.QtWidgets import (
QAbstractItemView,
+ QComboBox,
QDialog,
QHBoxLayout,
QHeaderView,
QLabel,
+ QLineEdit,
+ QMenu,
QMessageBox,
QPushButton,
QTableView,
@@ -64,6 +69,32 @@ def _format_size(size: int) -> str:
return f"{size:,} B"
+# Unit multipliers for the allocate size selector (binary, 1 KB = 1024 B).
+_SIZE_UNITS = (
+ ("B", 1),
+ ("KB", 1024),
+ ("MB", 1024 ** 2),
+ ("GB", 1024 ** 3),
+ ("TB", 1024 ** 4),
+)
+
+
+def _parse_amount(text: str) -> Optional[float]:
+ """Parse a positive amount (the number part of a size). None if invalid.
+
+ The unit (B/KB/MB/…) is chosen separately, so this only validates the
+ numeric value and allows fractions like ``1.5``.
+ """
+ cleaned = text.strip().replace("_", "").replace(",", "")
+ if not cleaned:
+ return None
+ try:
+ value = float(cleaned)
+ except ValueError:
+ return None
+ return value if value > 0 else None
+
+
def _decode_protection(region: Dict) -> str:
"""
Translate the platform-specific protection field into a short ``R W X`` /
@@ -159,12 +190,27 @@ def __init__(self, process: AbstractProcess, parent=None):
self._snapshot: List[Dict] = []
self._worker: Optional[_SnapshotWorker] = None
+ # allocate/free are a Windows/macOS capability (Linux raises
+ # NotImplementedError); the controls are disabled there.
+ self._supported = not sys.platform.startswith("linux")
+ # When set, the address to re-select after the next refresh finishes
+ # (so a freshly allocated region is highlighted once the map reloads).
+ self._pending_select: Optional[int] = None
+
self.setWindowTitle(f"Memory Map — PID {process.pid}")
self.resize(900, 580)
self._build_ui()
self.refresh()
+ # Auto-refresh the region list so allocations / frees (and any other
+ # mapping changes in the target) appear without a manual refresh. The
+ # refresh() guard self-throttles if a snapshot takes longer than this.
+ self._auto_timer = QTimer(self)
+ self._auto_timer.setInterval(1000)
+ self._auto_timer.timeout.connect(self.refresh)
+ self._auto_timer.start()
+
def _build_ui(self) -> None:
layout = QVBoxLayout(self)
layout.setContentsMargins(14, 14, 14, 14)
@@ -181,26 +227,29 @@ def _build_ui(self) -> None:
self._count_label.setObjectName("hint")
layout.addWidget(self._count_label)
+ # Top bar: actions on the current selection + a filter box. They all
+ # operate on the selected row, so they sit together above the table.
bar = QHBoxLayout()
bar.setSpacing(8)
- self._refresh_btn = QPushButton("Refresh")
- self._refresh_btn.clicked.connect(self.refresh)
- bar.addWidget(self._refresh_btn)
-
- self._copy_btn = QPushButton("Copy Address")
- self._copy_btn.clicked.connect(self._copy_selected_address)
- bar.addWidget(self._copy_btn)
-
self._hex_btn = QPushButton("Open in Hex Viewer")
self._hex_btn.clicked.connect(self._emit_hex_viewer_request)
bar.addWidget(self._hex_btn)
+ self._free_btn = QPushButton("Free Selected")
+ self._free_btn.setObjectName("danger")
+ self._free_btn.clicked.connect(self._on_free_selected)
+ bar.addWidget(self._free_btn)
+
bar.addStretch(1)
- close_btn = QPushButton("Close")
- close_btn.clicked.connect(self.accept)
- bar.addWidget(close_btn)
+ self._filter_edit = QLineEdit()
+ self._filter_edit.setPlaceholderText("Filter by path or address…")
+ self._filter_edit.setClearButtonEnabled(True)
+ self._filter_edit.setFixedWidth(240)
+ self._filter_edit.textChanged.connect(lambda _text: self._populate())
+ bar.addWidget(self._filter_edit)
+
layout.addLayout(bar)
self._model = QStandardItemModel(0, 6, self)
@@ -230,20 +279,103 @@ def _build_ui(self) -> None:
self._table.horizontalHeader().setSectionResizeMode(4, QHeaderView.Stretch)
self._table.setColumnHidden(5, True) # raw size column used only for sorting
self._table.doubleClicked.connect(lambda _i: self._emit_hex_viewer_request())
+ self._table.setContextMenuPolicy(Qt.CustomContextMenu)
+ self._table.customContextMenuRequested.connect(self._show_context_menu)
layout.addWidget(self._table, 1)
+ # Footer: the lone "create" action (Allocate) on the left, Close on the
+ # right. Both are pinned below the table, so they stay reachable no
+ # matter how long the region list is — only the table scrolls.
+ footer = QHBoxLayout()
+ footer.setSpacing(8)
+ footer.addWidget(QLabel("Size:"))
+
+ self._size_edit = QLineEdit()
+ self._size_edit.setPlaceholderText("amount")
+ self._size_edit.setFont(QFont("Menlo, Consolas, Courier New", 10))
+ self._size_edit.setFixedWidth(140)
+ self._size_edit.returnPressed.connect(self._on_allocate)
+ footer.addWidget(self._size_edit)
+
+ self._unit_combo = QComboBox()
+ for unit_label, factor in _SIZE_UNITS:
+ self._unit_combo.addItem(unit_label, factor)
+ self._unit_combo.setCurrentText("KB")
+ footer.addWidget(self._unit_combo)
+
+ self._allocate_btn = QPushButton("Allocate")
+ self._allocate_btn.setObjectName("secondary")
+ self._allocate_btn.clicked.connect(self._on_allocate)
+ footer.addWidget(self._allocate_btn)
+
+ footer.addStretch(1)
+
+ close_btn = QPushButton("Close")
+ close_btn.clicked.connect(self.accept)
+ footer.addWidget(close_btn)
+
+ # Wrap the footer row + a short caption explaining the allocate/free
+ # feature, shown right under the size input.
+ footer_box = QVBoxLayout()
+ footer_box.setSpacing(4)
+ footer_box.addLayout(footer)
+
+ caption = QLabel(
+ "Reserve a new block of memory in the target process — it appears "
+ "in the map above."
+ )
+ caption.setObjectName("hint")
+ caption.setWordWrap(True)
+ footer_box.addWidget(caption)
+
+ layout.addLayout(footer_box)
+
+ if not self._supported:
+ # Memory-region viewing still works on Linux; only allocate/free do
+ # not (no cross-process allocation syscall).
+ unsupported_tip = (
+ "Allocating / freeing memory in another process is not "
+ "supported on Linux."
+ )
+ for widget in (
+ self._free_btn,
+ self._size_edit,
+ self._unit_combo,
+ self._allocate_btn,
+ ):
+ widget.setEnabled(False)
+ widget.setToolTip(unsupported_tip)
+
+ # The theme's #danger / #secondary rules add `padding: 7px 14px;
+ # min-height: 20px`, making "Free Selected" and "Allocate" taller than
+ # the neutral buttons and the inputs (which use the plain QPushButton
+ # `padding: 5px 12px`). Override just those box-model properties — with
+ # the same selector so it wins as the later rule — to match the plain
+ # padding. The themed colors live in separate #danger / #secondary
+ # rules and are left untouched.
+ self._free_btn.setStyleSheet(
+ "QPushButton#danger { padding: 5px 12px; min-height: 0px; }"
+ )
+ self._allocate_btn.setStyleSheet(
+ "QPushButton#secondary { padding: 5px 12px; min-height: 0px; }"
+ )
+
def snapshot(self) -> List[Dict]:
"""Return the cached region snapshot so the scanner can reuse it."""
return list(self._snapshot)
def refresh(self) -> None:
- # Don't stack workers — if a previous refresh is in flight, ignore the
- # click. The UI is already disabled, so this is just a safety net.
+ # Skip if a snapshot is already in flight — the 1000ms auto-refresh timer
+ # would otherwise stack workers on a slow (huge) target. This makes the
+ # refresh self-throttle to however long a snapshot actually takes.
if self._worker is not None and self._worker.isRunning():
return
- self._count_label.setText("Loading memory regions…")
- self._set_busy(True)
+ # Only show the loading hint before the first snapshot; on the periodic
+ # refresh the count label updates silently to avoid flicker. The action
+ # controls stay enabled — disabling them every 1000ms would be unusable.
+ if not self._snapshot:
+ self._count_label.setText("Loading memory regions…")
worker = _SnapshotWorker(self._process, self)
worker.snapshot_ready.connect(self._on_snapshot_ready)
@@ -252,21 +384,42 @@ def refresh(self) -> None:
self._worker = worker
worker.start()
- def _set_busy(self, busy: bool) -> None:
- self._copy_btn.setEnabled(not busy)
- self._hex_btn.setEnabled(not busy)
- # The Refresh button is the first widget added to the toolbar — keep a
- # named reference instead of fishing through the layout.
- self._refresh_btn.setEnabled(not busy)
-
def _on_snapshot_ready(self, snapshot) -> None:
self._snapshot = list(snapshot)
+ self._populate()
+
+ def _populate(self) -> None:
+ """(Re)build the table from the cached snapshot, honoring the filter."""
+ needle = self._filter_edit.text().strip().lower()
+
+ # Preserve the user's selection and scroll position across the rebuild —
+ # the table repopulates every 1000ms, and losing them would make it
+ # impossible to keep a row selected or stay scrolled where you were.
+ prior_selection = None
+ selected_rows = self._table.selectionModel().selectedRows()
+ if selected_rows:
+ selected_item = self._model.item(selected_rows[0].row(), 0)
+ if selected_item is not None:
+ prior_selection = selected_item.data(Qt.UserRole)
+ scroll_value = self._table.verticalScrollBar().value()
+
+ total = len(self._snapshot)
+ total_bytes = sum(int(region["size"]) for region in self._snapshot)
+
+ # Disable sorting while rebuilding so the model isn't re-sorted on every
+ # appendRow (and rows don't shuffle mid-build).
+ self._table.setSortingEnabled(False)
self._model.setRowCount(0)
- total_bytes = 0
+
+ shown = 0
for region in self._snapshot:
addr = int(region["address"])
size = int(region["size"])
- total_bytes += size
+ path = _region_path(region) or ""
+
+ if needle and needle not in path.lower() and needle not in f"0x{addr:x}":
+ continue
+ shown += 1
addr_item = NumericItem(f"0x{addr:016X}")
addr_item.setData(addr, Qt.UserRole)
@@ -277,8 +430,6 @@ def _on_snapshot_ready(self, snapshot) -> None:
prot_item = QStandardItem(_decode_protection(region))
shared_item = QStandardItem(_region_shared(region))
-
- path = _region_path(region) or ""
path_item = QStandardItem(path)
raw_size_item = NumericItem(str(size))
@@ -288,9 +439,38 @@ def _on_snapshot_ready(self, snapshot) -> None:
[addr_item, size_item, prot_item, shared_item, path_item, raw_size_item]
)
- self._count_label.setText(
- f"{len(self._snapshot):,} regions · {_format_size(total_bytes)} of virtual address space mapped"
- )
+ self._table.setSortingEnabled(True)
+
+ if needle:
+ self._count_label.setText(
+ f"{shown:,} of {total:,} regions shown · "
+ f"{_format_size(total_bytes)} mapped"
+ )
+ else:
+ self._count_label.setText(
+ f"{total:,} regions · "
+ f"{_format_size(total_bytes)} of virtual address space mapped"
+ )
+
+ # Restore the view. A just-allocated region wins and is scrolled into
+ # view; otherwise re-select whatever was selected before and keep the
+ # scroll position, so the 1000ms refresh doesn't jump the table around.
+ if self._pending_select is not None:
+ self._select_address(self._pending_select, scroll=True)
+ self._pending_select = None
+ else:
+ if prior_selection is not None:
+ self._select_address(prior_selection, scroll=False)
+ self._table.verticalScrollBar().setValue(scroll_value)
+
+ def _select_address(self, address: int, *, scroll: bool = True) -> None:
+ """Select the row whose base address equals ``address`` (if present)."""
+ for row in range(self._model.rowCount()):
+ if self._model.item(row, 0).data(Qt.UserRole) == address:
+ self._table.selectRow(row)
+ if scroll:
+ self._table.scrollTo(self._model.index(row, 0))
+ return
def _on_snapshot_failed(self, message: str) -> None:
self._count_label.setText("Failed to read memory regions.")
@@ -299,13 +479,13 @@ def _on_snapshot_failed(self, message: str) -> None:
)
def _on_worker_finished(self) -> None:
- self._set_busy(False)
worker = self._worker
self._worker = None
if worker is not None:
worker.deleteLater()
def closeEvent(self, event): # noqa: N802 — Qt naming
+ self._auto_timer.stop()
# If the snapshot is still in flight, let it finish without holding
# the UI hostage but unhook our slots so a late emit doesn't touch
# a destroyed dialog.
@@ -328,12 +508,37 @@ def _selected_region(self) -> Optional[Dict]:
size = self._model.item(row, 1).data(Qt.UserRole)
return {"address": int(addr), "size": int(size)}
- def _copy_selected_address(self) -> None:
- region = self._selected_region()
- if region is None:
- QMessageBox.information(self, "Memory Map", "Select a region first.")
+ def _show_context_menu(self, pos) -> None:
+ """Right-click menu on a region row: copy its address or backing path."""
+ index = self._table.indexAt(pos)
+ if not index.isValid():
return
- QGuiApplication.clipboard().setText(f"{region['address']:X}")
+ self._table.selectRow(index.row()) # operate on the clicked row
+ menu = self._build_context_menu(index.row())
+ menu.exec(self._table.viewport().mapToGlobal(pos))
+
+ def _build_context_menu(self, row: int) -> QMenu:
+ """Build the row's right-click menu (Copy Address / Copy Path).
+
+ Each action copies via ``triggered`` so the behavior is identical
+ whether the menu is shown or driven programmatically.
+ """
+ address = int(self._model.item(row, 0).data(Qt.UserRole))
+ path = self._model.item(row, 4).text()
+
+ menu = QMenu(self)
+ copy_address = menu.addAction("Copy Address")
+ copy_address.triggered.connect(lambda: self._copy_text(f"{address:X}"))
+
+ copy_path = menu.addAction("Copy Path")
+ # No path for anonymous regions (and macOS doesn't expose paths) — keep
+ # the entry visible for consistency but disabled when there's nothing.
+ copy_path.setEnabled(bool(path))
+ copy_path.triggered.connect(lambda: self._copy_text(path))
+ return menu
+
+ def _copy_text(self, text: str) -> None:
+ QGuiApplication.clipboard().setText(text)
def _emit_hex_viewer_request(self) -> None:
region = self._selected_region()
@@ -343,3 +548,87 @@ def _emit_hex_viewer_request(self) -> None:
# Cap the initial view to keep the hex widget responsive on huge regions.
size = min(region["size"], 4096)
self.open_hex_viewer.emit(region["address"], size)
+
+ def _on_allocate(self) -> None:
+ amount = _parse_amount(self._size_edit.text())
+ if amount is None:
+ QMessageBox.warning(
+ self,
+ "Allocate",
+ "Enter a positive amount (e.g. 4 or 1.5) and pick a unit.",
+ )
+ return
+
+ factor = int(self._unit_combo.currentData())
+ size = int(amount * factor)
+ if size <= 0:
+ QMessageBox.warning(
+ self,
+ "Allocate",
+ "That amount rounds down to 0 bytes — pick a larger value or unit.",
+ )
+ return
+
+ try:
+ address = self._process.allocate_memory(size)
+ except Exception as exc: # noqa: BLE001
+ QMessageBox.critical(
+ self,
+ "Allocate",
+ f"Could not allocate {size} byte(s):\n\n{type(exc).__name__}: {exc}",
+ )
+ return
+
+ QMessageBox.information(
+ self, "Memory allocated", f"Allocated memory at 0x{address:X}."
+ )
+ self._size_edit.clear()
+ # Reload the map so the new region appears, then select it.
+ self._pending_select = int(address)
+ self.refresh()
+
+ def _on_free_selected(self) -> None:
+ region = self._selected_region()
+ if region is None:
+ QMessageBox.information(self, "Memory Map", "Select a region to free first.")
+ return
+
+ address = region["address"]
+ reply = QMessageBox.warning(
+ self,
+ "Free memory",
+ f"Free the region at 0x{address:X}?\n\n"
+ "This releases memory previously reserved with Allocate. Only "
+ "regions allocated through this tool can be freed.",
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
+ QMessageBox.StandardButton.No,
+ )
+ if reply != QMessageBox.StandardButton.Yes:
+ return
+
+ # No size argument: the process reuses the size it recorded at allocation
+ # time. The OS often coalesces a fresh allocation with neighbours in the
+ # region map, so the *displayed* size can be larger than what we
+ # allocated — freeing that wider span would target memory we don't own.
+ try:
+ self._process.free_memory(address)
+ except ValueError:
+ # macOS: address isn't a tracked allocation (unknown size). Refuse
+ # rather than guess a size and risk tearing down unrelated memory.
+ QMessageBox.warning(
+ self,
+ "Free memory",
+ f"0x{address:X} was not allocated through this tool, so its size "
+ "is unknown and it can't be freed here. Use the Allocate box to "
+ "create regions you can free.",
+ )
+ return
+ except Exception as exc: # noqa: BLE001
+ QMessageBox.critical(
+ self,
+ "Free memory",
+ f"Could not free 0x{address:X}:\n\n{type(exc).__name__}: {exc}",
+ )
+ return
+
+ self.refresh()
diff --git a/PyMemoryEditor/app/modules_dialog.py b/PyMemoryEditor/app/modules_dialog.py
index 64b23ca..dd07171 100644
--- a/PyMemoryEditor/app/modules_dialog.py
+++ b/PyMemoryEditor/app/modules_dialog.py
@@ -4,13 +4,15 @@
Lists every module (the main executable plus each loaded shared library) the
target process has mapped, with its name, base address, size and backing path.
-The toolbar lets the user:
+The dialog lets the user:
-* refresh the list,
* filter by name / path (a real process loads hundreds of modules),
-* copy a module's base address,
+* right-click a module to copy its name, base address or path,
* jump straight into the hex viewer at the module base.
+The list auto-refreshes every 1000 ms, so modules loaded/unloaded at runtime
+appear without a manual refresh.
+
The base address is the most useful field here: combined with a static offset
(``base + offset``) it survives ASLR, which is exactly what the Pointer Chain
tool consumes. Lives alongside the Memory Map and Threads dialogs — same shape,
@@ -18,7 +20,7 @@
"""
from typing import List, Optional
-from PySide6.QtCore import Qt, QThread, Signal
+from PySide6.QtCore import Qt, QThread, QTimer, Signal
from PySide6.QtGui import QGuiApplication, QStandardItem, QStandardItemModel
from PySide6.QtWidgets import (
QAbstractItemView,
@@ -27,6 +29,7 @@
QHeaderView,
QLabel,
QLineEdit,
+ QMenu,
QMessageBox,
QPushButton,
QTableView,
@@ -76,6 +79,14 @@ def __init__(self, process: AbstractProcess, parent=None):
self._build_ui()
self.refresh()
+ # Auto-refresh so modules loaded/unloaded at runtime appear without a
+ # manual refresh. The refresh() guard self-throttles if an enumeration
+ # takes longer than this interval.
+ self._auto_timer = QTimer(self)
+ self._auto_timer.setInterval(1000)
+ self._auto_timer.timeout.connect(self.refresh)
+ self._auto_timer.start()
+
def _build_ui(self) -> None:
layout = QVBoxLayout(self)
layout.setContentsMargins(14, 14, 14, 14)
@@ -95,14 +106,6 @@ def _build_ui(self) -> None:
bar = QHBoxLayout()
bar.setSpacing(8)
- self._refresh_btn = QPushButton("Refresh")
- self._refresh_btn.clicked.connect(self.refresh)
- bar.addWidget(self._refresh_btn)
-
- self._copy_btn = QPushButton("Copy Base Address")
- self._copy_btn.clicked.connect(self._copy_selected_address)
- bar.addWidget(self._copy_btn)
-
self._hex_btn = QPushButton("Open in Hex Viewer")
self._hex_btn.clicked.connect(self._emit_hex_viewer_request)
bar.addWidget(self._hex_btn)
@@ -146,15 +149,21 @@ def _build_ui(self) -> None:
self._table.horizontalHeader().setSectionResizeMode(3, QHeaderView.Stretch)
self._table.setColumnHidden(4, True)
self._table.doubleClicked.connect(lambda _i: self._emit_hex_viewer_request())
+ self._table.setContextMenuPolicy(Qt.CustomContextMenu)
+ self._table.customContextMenuRequested.connect(self._show_context_menu)
layout.addWidget(self._table, 1)
def refresh(self) -> None:
- # Don't stack workers — if a refresh is in flight, ignore the click.
+ # Skip if an enumeration is already in flight — the 1000ms auto-refresh
+ # timer would otherwise stack workers. This self-throttles to however
+ # long get_modules() actually takes.
if self._worker is not None and self._worker.isRunning():
return
- self._count_label.setText("Enumerating modules…")
- self._set_busy(True)
+ # Loading hint only before the first list; on the periodic refresh the
+ # count updates silently to avoid flicker.
+ if not self._modules:
+ self._count_label.setText("Enumerating modules…")
worker = _ModulesWorker(self._process, self)
worker.modules_ready.connect(self._on_modules_ready)
@@ -163,11 +172,6 @@ def refresh(self) -> None:
self._worker = worker
worker.start()
- def _set_busy(self, busy: bool) -> None:
- self._refresh_btn.setEnabled(not busy)
- self._copy_btn.setEnabled(not busy)
- self._hex_btn.setEnabled(not busy)
-
def _on_modules_ready(self, modules) -> None:
self._modules = list(modules)
self._apply_filter()
@@ -176,6 +180,16 @@ def _apply_filter(self) -> None:
"""Repopulate the table from the cached module list, honoring the filter."""
needle = self._filter_edit.text().strip().lower()
+ # Preserve selection + scroll across the rebuild (the list auto-refreshes
+ # every 1000ms; losing them would make the table unusable).
+ prior_selection = None
+ selected_rows = self._table.selectionModel().selectedRows()
+ if selected_rows:
+ item = self._model.item(selected_rows[0].row(), 1)
+ if item is not None:
+ prior_selection = item.data(Qt.UserRole)
+ scroll_value = self._table.verticalScrollBar().value()
+
# Sorting is re-applied by the view; disable it while we rebuild so the
# model isn't re-sorted on every appendRow (also avoids row shuffling).
self._table.setSortingEnabled(False)
@@ -215,6 +229,19 @@ def _apply_filter(self) -> None:
else:
self._count_label.setText(f"{total:,} module(s)")
+ # Restore the user's selection + scroll, so the periodic refresh doesn't
+ # clear what they had highlighted or jump the table around.
+ if prior_selection is not None:
+ self._select_address(prior_selection)
+ self._table.verticalScrollBar().setValue(scroll_value)
+
+ def _select_address(self, address: int) -> None:
+ """Re-select the row whose base address matches (no scrolling)."""
+ for row in range(self._model.rowCount()):
+ if self._model.item(row, 1).data(Qt.UserRole) == address:
+ self._table.selectRow(row)
+ return
+
def _on_modules_failed(self, message: str) -> None:
self._count_label.setText("Failed to enumerate modules.")
QMessageBox.critical(
@@ -222,7 +249,6 @@ def _on_modules_failed(self, message: str) -> None:
)
def _on_worker_finished(self) -> None:
- self._set_busy(False)
worker = self._worker
self._worker = None
if worker is not None:
@@ -237,12 +263,43 @@ def _selected_module(self) -> Optional[dict]:
size = self._model.item(row, 2).data(Qt.UserRole)
return {"base_address": int(base), "size": int(size)}
- def _copy_selected_address(self) -> None:
- module = self._selected_module()
- if module is None:
- QMessageBox.information(self, "Modules", "Select a module first.")
+ def _show_context_menu(self, pos) -> None:
+ """Right-click menu on a module row: copy its name, address or path."""
+ index = self._table.indexAt(pos)
+ if not index.isValid():
return
- QGuiApplication.clipboard().setText(f"{module['base_address']:X}")
+ self._table.selectRow(index.row()) # operate on the clicked row
+ menu = self._build_context_menu(index.row())
+ menu.exec(self._table.viewport().mapToGlobal(pos))
+
+ def _build_context_menu(self, row: int) -> QMenu:
+ """Build the row's right-click menu (Copy Name / Address / Path).
+
+ Each action copies via ``triggered`` so the behavior is identical
+ whether the menu is shown or driven programmatically.
+ """
+ name = self._model.item(row, 0).text()
+ address = int(self._model.item(row, 1).data(Qt.UserRole))
+ path = self._model.item(row, 3).text()
+
+ menu = QMenu(self)
+
+ copy_name = menu.addAction("Copy Name")
+ copy_name.setEnabled(bool(name) and name != "—")
+ copy_name.triggered.connect(lambda: self._copy_text(name))
+
+ copy_address = menu.addAction("Copy Address")
+ copy_address.triggered.connect(lambda: self._copy_text(f"{address:X}"))
+
+ copy_path = menu.addAction("Copy Path")
+ # Modules usually have a path; keep the entry visible but disabled when
+ # the backend couldn't resolve one.
+ copy_path.setEnabled(bool(path))
+ copy_path.triggered.connect(lambda: self._copy_text(path))
+ return menu
+
+ def _copy_text(self, text: str) -> None:
+ QGuiApplication.clipboard().setText(text)
def _emit_hex_viewer_request(self) -> None:
module = self._selected_module()
@@ -254,6 +311,7 @@ def _emit_hex_viewer_request(self) -> None:
self.open_hex_viewer.emit(module["base_address"], size)
def closeEvent(self, event): # noqa: N802 — Qt naming
+ self._auto_timer.stop()
# If the enumeration is still in flight, let it finish but unhook our
# slots so a late emit doesn't touch a destroyed dialog.
if self._worker is not None and self._worker.isRunning():
diff --git a/PyMemoryEditor/app/pointer_chain_dialog.py b/PyMemoryEditor/app/pointer_chain_dialog.py
index 0a623da..dbe9487 100644
--- a/PyMemoryEditor/app/pointer_chain_dialog.py
+++ b/PyMemoryEditor/app/pointer_chain_dialog.py
@@ -2,10 +2,10 @@
"""
Pointer-chain dialog — exposes ``process.resolve_pointer_chain()``.
-The intent is to be a *direct paste path* from a Cheat-Engine cheat table.
-Cheat-Engine writes chains like::
+It resolves a multi-level pointer — a static base plus a series of bracketed
+offsets — written like::
- "game.exe" + 0x10F4F4 -> [+0x0] -> [+0x158] ; HP
+ "game.exe" + 0x10F4F4 -> [+0x0] -> [+0x158]
This dialog asks for:
@@ -57,9 +57,9 @@
class _OffsetField(QWidget):
"""One slot in the offsets chain — visually ``[+ ]``.
- Cheat-Engine notation uses ``[+0x10] -> [+0x20]`` to show a pointer
- walk; we mirror that with a label-input-label triple per offset so the
- "list of offsets" reads as a chain instead of as a free-form text field.
+ A pointer walk is written ``[+0x10] -> [+0x20]``; we mirror that with a
+ label-input-label triple per offset so the "list of offsets" reads as a
+ chain instead of as a free-form text field.
The trailing ``×`` removes this field; the parent dialog hides the
button when only one slot remains.
"""
@@ -127,9 +127,9 @@ def _build_ui(self) -> None:
layout.addWidget(header)
hint = QLabel(
- "Paste a Cheat Engine-style chain. The base address can be a static "
- "offset inside the executable (module base + offset) or a known "
- "pointer in memory."
+ "Enter a pointer chain. The base address can be a static offset "
+ "inside the executable (module base + offset) or a known pointer "
+ "in memory."
)
hint.setObjectName("hint")
hint.setWordWrap(True)
@@ -143,7 +143,7 @@ def _build_ui(self) -> None:
self._base_edit.setPlaceholderText("e.g. 0x14010F4F4 (hex)")
form.addRow("Base address:", self._base_edit)
- # Offsets row — Cheat-Engine style chain of "[+ hex ]" slots with a
+ # Offsets row — a chain of "[+ hex ]" slots with a
# trailing "+" button to add another hop. Wrapped in a horizontal
# scroll area so deep chains (10+ levels) don't blow up the dialog
# width.
@@ -181,9 +181,9 @@ def _build_ui(self) -> None:
self._ptr_size_combo.addItem("4 bytes (32-bit)", 4)
form.addRow("Pointer size:", self._ptr_size_combo)
- # The CE-style chain assumes ``base`` is a *static slot* in the
+ # By default the chain assumes ``base`` is a *static slot* in the
# executable that holds a pointer (so we dereference once before
- # walking offsets). Users who paste a *direct* address (e.g. from
+ # walking offsets). Users who pass a *direct* address (e.g. from
# the Memory Map or a fresh scan) want ``base`` to be the final
# address itself — offsets in that case are struct-field offsets,
# added without any extra dereference.
@@ -192,9 +192,8 @@ def _build_ui(self) -> None:
)
self._deref_check.setChecked(True)
self._deref_check.setToolTip(
- "Checked (Cheat-Engine style): base address holds a pointer; "
- "the resolver reads that pointer, then dereferences again on each "
- "offset.\n\n"
+ "Checked: base address holds a pointer; the resolver reads that "
+ "pointer, then dereferences again on each offset.\n\n"
"Unchecked: base is the final address itself. Offsets are added "
"without dereferencing — useful when you pasted an address from "
"the Memory Map or want a struct field at base+offset."
@@ -316,7 +315,7 @@ def _read_offsets(self) -> Optional[List[int]]:
# parse_hex_address only handles full hex addresses with or
# without ``0x``; try a plain hex int as a fallback for tokens
# like ``"10"`` that look ambiguous (decimal vs hex). The
- # whole dialog treats offsets as hex, matching Cheat Engine.
+ # whole dialog treats offsets as hex.
try:
parsed = int(text, 16)
except ValueError:
diff --git a/PyMemoryEditor/app/threads_dialog.py b/PyMemoryEditor/app/threads_dialog.py
index 0ea0e88..d9f6e9e 100644
--- a/PyMemoryEditor/app/threads_dialog.py
+++ b/PyMemoryEditor/app/threads_dialog.py
@@ -18,14 +18,12 @@
from PySide6.QtGui import QStandardItem, QStandardItemModel
from PySide6.QtWidgets import (
QAbstractItemView,
- QCheckBox,
QDialog,
QHBoxLayout,
QHeaderView,
QLabel,
QMessageBox,
QPushButton,
- QSpinBox,
QTableView,
QVBoxLayout,
)
@@ -67,14 +65,15 @@ def __init__(self, process: AbstractProcess, parent=None):
self.resize(640, 520)
self._build_ui()
+ self.refresh()
- # Auto-refresh timer; off by default. The interval is matched to the
- # main window's heartbeat so the user only sees consistent data even
- # if both fire on the same tick.
+ # Auto-refresh at a fixed 300ms — threads spawn and exit often, so a
+ # brisk cadence lets the user watch the churn live. The refresh() guard
+ # self-throttles if an enumeration takes longer than this interval.
self._timer = QTimer(self)
+ self._timer.setInterval(300)
self._timer.timeout.connect(self.refresh)
-
- self.refresh()
+ self._timer.start()
def _build_ui(self) -> None:
layout = QVBoxLayout(self)
@@ -95,28 +94,8 @@ def _build_ui(self) -> None:
bar = QHBoxLayout()
bar.setSpacing(8)
- self._refresh_btn = QPushButton("Refresh")
- self._refresh_btn.clicked.connect(self.refresh)
- bar.addWidget(self._refresh_btn)
-
bar.addStretch(1)
- self._auto_check = QCheckBox("Auto-refresh")
- self._auto_check.setToolTip(
- "Poll get_threads() at the interval below. Threads die and "
- "spawn often — leaving this on lets you watch the churn."
- )
- self._auto_check.toggled.connect(self._toggle_auto_refresh)
- bar.addWidget(self._auto_check)
-
- bar.addWidget(QLabel("ms:"))
- self._interval_spin = QSpinBox()
- self._interval_spin.setRange(200, 10000)
- self._interval_spin.setSingleStep(100)
- self._interval_spin.setValue(1000)
- self._interval_spin.valueChanged.connect(self._sync_timer)
- bar.addWidget(self._interval_spin)
-
close_btn = QPushButton("Close")
close_btn.clicked.connect(self.accept)
bar.addWidget(close_btn)
@@ -148,11 +127,15 @@ def _build_ui(self) -> None:
layout.addWidget(self._table, 1)
def refresh(self) -> None:
+ # Skip if an enumeration is in flight — the 300ms timer would otherwise
+ # stack workers; this self-throttles to however long get_threads() takes.
if self._worker is not None and self._worker.isRunning():
return
- self._set_busy(True)
- self._count_label.setText("Enumerating threads…")
+ # Loading hint only before the first list; the periodic refresh updates
+ # the count silently to avoid flicker.
+ if not self._threads:
+ self._count_label.setText("Enumerating threads…")
worker = _ThreadsWorker(self._process, self)
worker.threads_ready.connect(self._on_threads_ready)
@@ -161,11 +144,19 @@ def refresh(self) -> None:
self._worker = worker
worker.start()
- def _set_busy(self, busy: bool) -> None:
- self._refresh_btn.setEnabled(not busy)
-
def _on_threads_ready(self, threads) -> None:
self._threads = list(threads)
+
+ # Preserve selection + scroll across the rebuild (the list refreshes
+ # every 300ms; losing them would make the table unusable).
+ prior_tid = None
+ selected_rows = self._table.selectionModel().selectedRows()
+ if selected_rows:
+ item = self._model.item(selected_rows[0].row(), 0)
+ if item is not None:
+ prior_tid = item.data(Qt.UserRole)
+ scroll_value = self._table.verticalScrollBar().value()
+
self._model.setRowCount(0)
for info in self._threads:
tid_item = NumericItem(str(info.tid))
@@ -195,6 +186,19 @@ def _on_threads_ready(self, threads) -> None:
f"{len(self._threads):,} thread(s){main_str}"
)
+ # Restore the user's selection + scroll so the periodic refresh doesn't
+ # clear what they had highlighted or jump the table around.
+ if prior_tid is not None:
+ self._select_tid(prior_tid)
+ self._table.verticalScrollBar().setValue(scroll_value)
+
+ def _select_tid(self, tid: int) -> None:
+ """Re-select the row whose TID matches (no scrolling)."""
+ for row in range(self._model.rowCount()):
+ if self._model.item(row, 0).data(Qt.UserRole) == tid:
+ self._table.selectRow(row)
+ return
+
def _on_threads_failed(self, message: str) -> None:
self._count_label.setText("Failed to enumerate threads.")
QMessageBox.critical(
@@ -202,23 +206,11 @@ def _on_threads_failed(self, message: str) -> None:
)
def _on_worker_finished(self) -> None:
- self._set_busy(False)
worker = self._worker
self._worker = None
if worker is not None:
worker.deleteLater()
- def _toggle_auto_refresh(self, on: bool) -> None:
- if on:
- self._sync_timer()
- else:
- self._timer.stop()
-
- def _sync_timer(self) -> None:
- self._timer.setInterval(int(self._interval_spin.value()))
- if self._auto_check.isChecked() and not self._timer.isActive():
- self._timer.start()
-
def closeEvent(self, event): # noqa: N802 — Qt naming
self._timer.stop()
if self._worker is not None and self._worker.isRunning():
diff --git a/PyMemoryEditor/linux/process.py b/PyMemoryEditor/linux/process.py
index 2558c43..d02ef6b 100644
--- a/PyMemoryEditor/linux/process.py
+++ b/PyMemoryEditor/linux/process.py
@@ -207,3 +207,21 @@ def write_process_memory(
return write_process_memory(
self.pid, address, pytype, resolve_bufflength(pytype, bufflength), value
)
+
+ def allocate_memory(self, size: int, *, permission=None) -> int:
+ self.__require_open()
+ raise NotImplementedError(
+ "allocate_memory is not supported on Linux: there is no syscall to "
+ "allocate memory in another process's address space (mmap only "
+ "affects the calling process). Doing so would require a ptrace-based "
+ "engine to make the target call mmap itself. Use the Windows or "
+ "macOS backend for cross-process allocation."
+ )
+
+ def free_memory(self, address: int, size: int = 0) -> bool:
+ self.__require_open()
+ raise NotImplementedError(
+ "free_memory is not supported on Linux (see allocate_memory): "
+ "releasing memory in another process would require a ptrace-based "
+ "engine to make the target call munmap itself."
+ )
diff --git a/PyMemoryEditor/macos/functions.py b/PyMemoryEditor/macos/functions.py
index 4931eb5..b4daefd 100644
--- a/PyMemoryEditor/macos/functions.py
+++ b/PyMemoryEditor/macos/functions.py
@@ -37,6 +37,7 @@
MEMORY_BASIC_INFORMATION,
TASK_DYLD_INFO,
TASK_DYLD_INFO_COUNT,
+ VM_FLAGS_ANYWHERE,
VM_PROT_COPY,
VM_PROT_READ,
VM_PROT_WRITE,
@@ -360,6 +361,62 @@ def write_process_memory(
return value
+def allocate_memory(task: int, size: int, permission=None) -> int:
+ """
+ Allocate ``size`` bytes in the task via mach_vm_allocate and return the
+ base address. The kernel chooses the address (VM_FLAGS_ANYWHERE) and the
+ region starts as read+write.
+
+ :param permission: optional VM_PROT_* bitmask. When given, the region's
+ protection is set with mach_vm_protect after allocation; on failure the
+ allocation is rolled back so it is not leaked. Requesting execute may be
+ refused by the hardened runtime (e.g. RWX on Apple Silicon).
+ """
+ if size <= 0:
+ raise ValueError("size must be a positive number of bytes.")
+
+ address = mach_vm_address_t(0)
+ kr = libsystem.mach_vm_allocate(
+ task, ctypes.byref(address), size, VM_FLAGS_ANYWHERE
+ )
+ if kr != KERN_SUCCESS:
+ raise OSError(
+ "mach_vm_allocate failed: %s (kr=%d)" % (mach_error_message(kr), kr)
+ )
+
+ if permission is not None:
+ kr = libsystem.mach_vm_protect(task, address.value, size, 0, int(permission))
+ if kr != KERN_SUCCESS:
+ # Don't leak the region we just created if we can't honor the
+ # requested protection.
+ libsystem.mach_vm_deallocate(task, address.value, size)
+ raise OSError(
+ "mach_vm_protect failed after allocate: %s (kr=%d)"
+ % (mach_error_message(kr), kr)
+ )
+
+ return int(address.value)
+
+
+def free_memory(task: int, address: int, size: int) -> bool:
+ """
+ Release a region previously returned by :func:`allocate_memory` via
+ mach_vm_deallocate. Unlike Windows' MEM_RELEASE, Mach requires the exact
+ size, so the caller must pass it (the process wrapper tracks it).
+ """
+ if size <= 0:
+ raise ValueError(
+ "macOS requires the allocation size to free a region (got %r)." % (size,)
+ )
+
+ kr = libsystem.mach_vm_deallocate(task, address, size)
+ if kr != KERN_SUCCESS:
+ raise OSError(
+ "mach_vm_deallocate failed: %s (kr=%d)" % (mach_error_message(kr), kr)
+ )
+ return True
+
+
def search_addresses_by_value(
task: int,
pytype: Type[T],
diff --git a/PyMemoryEditor/macos/libsystem.py b/PyMemoryEditor/macos/libsystem.py
index 6343ec1..e6ff479 100644
--- a/PyMemoryEditor/macos/libsystem.py
+++ b/PyMemoryEditor/macos/libsystem.py
@@ -107,6 +107,30 @@
libsystem.mach_port_deallocate.argtypes = (mach_port_t, mach_port_t)
libsystem.mach_port_deallocate.restype = kern_return_t
+# kern_return_t mach_vm_allocate(
+# vm_map_t target,
+# mach_vm_address_t *address, /* in/out: requested / chosen address */
+# mach_vm_size_t size,
+# int flags);
+libsystem.mach_vm_allocate.argtypes = (
+ vm_map_t,
+ POINTER(mach_vm_address_t),
+ mach_vm_size_t,
+ ctypes.c_int,
+)
+libsystem.mach_vm_allocate.restype = kern_return_t
+
+# kern_return_t mach_vm_deallocate(
+# vm_map_t target,
+# mach_vm_address_t address,
+# mach_vm_size_t size);
+libsystem.mach_vm_deallocate.argtypes = (
+ vm_map_t,
+ mach_vm_address_t,
+ mach_vm_size_t,
+)
+libsystem.mach_vm_deallocate.restype = kern_return_t
+
# kern_return_t task_info(
# task_name_t target_task,
# task_flavor_t flavor,
diff --git a/PyMemoryEditor/macos/process.py b/PyMemoryEditor/macos/process.py
index 69a891c..996ff44 100644
--- a/PyMemoryEditor/macos/process.py
+++ b/PyMemoryEditor/macos/process.py
@@ -11,6 +11,8 @@
from ..util import resolve_bufflength
from .functions import (
+ allocate_memory,
+ free_memory,
get_memory_regions,
get_modules,
get_task_for_pid,
@@ -81,6 +83,10 @@ def __init__(
self.__closed = False
self.__task = get_task_for_pid(self.pid)
+ # Base address -> allocated size; lets free_memory(address) work without
+ # the caller tracking sizes (Mach's mach_vm_deallocate needs the size).
+ self.__allocations: Dict[int, int] = {}
+
def __require_open(self) -> None:
if self.__closed:
raise ClosedProcess()
@@ -267,3 +273,23 @@ def write_process_memory(
return write_process_memory(
self.__task, address, pytype, resolve_bufflength(pytype, bufflength), value
)
+
+ def allocate_memory(self, size: int, *, permission=None) -> int:
+ self.__require_open()
+ address = allocate_memory(self.__task, size, permission)
+ self.__allocations[address] = size
+ return address
+
+ def free_memory(self, address: int, size: int = 0) -> bool:
+ self.__require_open()
+ # mach_vm_deallocate needs the exact size. Reuse the tracked size when
+ # the caller doesn't supply one.
+ actual_size = size or self.__allocations.get(address, 0)
+ if actual_size <= 0:
+ raise ValueError(
+ "Unknown allocation at 0x%X — pass an explicit size= to free a "
+ "region this object did not allocate." % address
+ )
+ free_memory(self.__task, address, actual_size)
+ self.__allocations.pop(address, None)
+ return True
diff --git a/PyMemoryEditor/macos/types.py b/PyMemoryEditor/macos/types.py
index e5c18b3..214cf95 100644
--- a/PyMemoryEditor/macos/types.py
+++ b/PyMemoryEditor/macos/types.py
@@ -44,6 +44,9 @@
VM_PROT_EXECUTE = 0x04
VM_PROT_COPY = 0x10 # Used with mach_vm_protect on read-only/mapped pages.
+# mach_vm_allocate flag: let the kernel pick the address (anywhere it fits).
+VM_FLAGS_ANYWHERE = 0x0001
+
# Selected kern_return_t values
KERN_SUCCESS = 0
KERN_INVALID_ADDRESS = 1
diff --git a/PyMemoryEditor/process/abstract.py b/PyMemoryEditor/process/abstract.py
index 3576939..48a0a0b 100644
--- a/PyMemoryEditor/process/abstract.py
+++ b/PyMemoryEditor/process/abstract.py
@@ -302,6 +302,53 @@ def write_process_memory(
"""
raise NotImplementedError()
+ @abstractmethod
+ def allocate_memory(self, size: int, *, permission=None) -> int:
+ """
+ Reserve and commit ``size`` bytes inside the target process's address
+ space and return the base address of the new region.
+
+ The returned address is owned by the target and survives until you pass
+ it to :meth:`free_memory`. Write to it with :meth:`write_process_memory`
+ like any other address. The library remembers the size of each
+ allocation, so ``free_memory(address)`` works without you tracking it.
+
+ :param size: number of bytes to allocate (rounded up to the OS page
+ size by the kernel).
+ :param permission: optional, **platform-specific** protection for the
+ new region — same spirit as ``OpenProcess(permission=...)``:
+
+ * **Windows**: a ``MemoryProtectionsEnum`` / ``PAGE_*`` value.
+ Defaults to ``PAGE_EXECUTE_READWRITE`` (read/write/execute) so the
+ region is usable for both data and injected code.
+ * **macOS**: a ``VM_PROT_*`` bitmask. ``None`` leaves the Mach
+ default (read+write). Requesting execute may fail under the
+ hardened runtime (notably RWX on Apple Silicon).
+ * **Linux**: not supported — see below.
+
+ :raises NotImplementedError: on Linux, which has no cross-process
+ allocation syscall (it would require a ptrace-based code-injection
+ engine to make the target call ``mmap`` itself).
+ """
+ raise NotImplementedError()
+
+ @abstractmethod
+ def free_memory(self, address: int, size: int = 0) -> bool:
+ """
+ Release a region previously returned by :meth:`allocate_memory`.
+
+ :param address: base address returned by :meth:`allocate_memory`.
+ :param size: size of the region in bytes. May be left ``0`` to reuse
+ the size recorded when the region was allocated (required on macOS,
+ ignored on Windows where ``MEM_RELEASE`` frees the whole
+ allocation). Pass an explicit size only to free a region this
+ object did not allocate.
+ :return: ``True`` on success.
+
+ :raises NotImplementedError: on Linux (see :meth:`allocate_memory`).
+ """
+ raise NotImplementedError()
+
def resolve_pointer_chain(
self,
base_address: int,
diff --git a/PyMemoryEditor/win32/functions.py b/PyMemoryEditor/win32/functions.py
index 29562bb..199ca28 100644
--- a/PyMemoryEditor/win32/functions.py
+++ b/PyMemoryEditor/win32/functions.py
@@ -138,6 +138,42 @@
)
kernel32.Module32Next.restype = ctypes.wintypes.BOOL
+# LPVOID VirtualAllocEx(HANDLE hProcess, LPVOID lpAddress, SIZE_T dwSize,
+# DWORD flAllocationType, DWORD flProtect);
+kernel32.VirtualAllocEx.argtypes = (
+ ctypes.wintypes.HANDLE,
+ ctypes.wintypes.LPVOID,
+ ctypes.c_size_t,
+ ctypes.wintypes.DWORD,
+ ctypes.wintypes.DWORD,
+)
+kernel32.VirtualAllocEx.restype = ctypes.wintypes.LPVOID
+
+# BOOL VirtualFreeEx(HANDLE hProcess, LPVOID lpAddress, SIZE_T dwSize,
+# DWORD dwFreeType);
+kernel32.VirtualFreeEx.argtypes = (
+ ctypes.wintypes.HANDLE,
+ ctypes.wintypes.LPVOID,
+ ctypes.c_size_t,
+ ctypes.wintypes.DWORD,
+)
+kernel32.VirtualFreeEx.restype = ctypes.wintypes.BOOL
+
+
+# VirtualAllocEx flAllocationType: reserve address space *and* back it with
+# physical storage in one call.
+_MEM_COMMIT_RESERVE = (
+ MemoryAllocationStatesEnum.MEM_COMMIT.value
+ | MemoryAllocationStatesEnum.MEM_RESERVE.value
+)
+# VirtualFreeEx dwFreeType. MEM_RELEASE frees the entire allocation and
+# requires dwSize == 0. Not in MemoryAllocationStatesEnum (that enum models
+# MBI.State / allocation-time flags), so it is defined locally.
+_MEM_RELEASE = 0x8000
+# Default protection for a fresh allocation: read/write/execute, so the region
+# works for both data and injected code (matches the common tooling default).
+_DEFAULT_ALLOC_PROTECT = MemoryProtectionsEnum.PAGE_EXECUTE_READWRITE.value
+
system_information = SYSTEM_INFO()
kernel32.GetSystemInfo(ctypes.byref(system_information))
@@ -585,6 +621,45 @@ def GetModules(pid: int) -> Generator[ModuleInfo, None, None]:
kernel32.CloseHandle(snapshot)
+def AllocateMemory(process_handle: int, size: int, permission=None) -> int:
+ """
+ Commit ``size`` bytes in the target process via VirtualAllocEx and return
+ the base address. Raises OSError if the allocation fails.
+
+ :param permission: PAGE_* protection (``MemoryProtectionsEnum`` or int).
+ Defaults to PAGE_EXECUTE_READWRITE.
+ """
+ if size <= 0:
+ raise ValueError("size must be a positive number of bytes.")
+
+ protect = _DEFAULT_ALLOC_PROTECT if permission is None else int(permission)
+
+ ctypes.set_last_error(0)
+ address = kernel32.VirtualAllocEx(
+ process_handle, None, size, _MEM_COMMIT_RESERVE, protect
+ )
+ if not address:
+ _raise_last_error("VirtualAllocEx")
+ return int(address)
+
+
+def FreeMemory(process_handle: int, address: int, size: int = 0) -> bool:
+ """
+ Release a region previously returned by :func:`AllocateMemory` via
+ VirtualFreeEx with MEM_RELEASE. Raises OSError if the free fails.
+
+ ``size`` is ignored — MEM_RELEASE requires it to be 0 and frees the whole
+ allocation — but is accepted for a uniform cross-platform signature.
+ """
+ ctypes.set_last_error(0)
+ ok = kernel32.VirtualFreeEx(
+ process_handle, ctypes.c_void_p(address), 0, _MEM_RELEASE
+ )
+ if not ok:
+ _raise_last_error("VirtualFreeEx")
+ return True
+
+
def WriteProcessMemory(
process_handle: int,
address: int,
diff --git a/PyMemoryEditor/win32/process.py b/PyMemoryEditor/win32/process.py
index 43d38cf..76600aa 100644
--- a/PyMemoryEditor/win32/process.py
+++ b/PyMemoryEditor/win32/process.py
@@ -13,7 +13,9 @@
from .enums import ProcessOperationsEnum
from .functions import (
+ AllocateMemory,
CloseProcessHandle,
+ FreeMemory,
GetMemoryRegions,
GetModules,
GetProcessHandle,
@@ -107,6 +109,10 @@ def __init__(
)
self.__closed = False
+ # Base address -> allocated size, so free_memory(address) can be called
+ # without the caller tracking sizes.
+ self.__allocations: Dict[int, int] = {}
+
self.__permission_value = _permission_value(permission)
self.__process_handle = GetProcessHandle(
@@ -117,6 +123,15 @@ def __require_open(self) -> None:
if self.__closed:
raise ClosedProcess()
+ def __require_operation(self) -> None:
+ # VirtualAllocEx / VirtualFreeEx need PROCESS_VM_OPERATION.
+ has_op = bool(self.__permission_value & _PROCESS_VM_OPERATION)
+ if not (has_op or _has_all_access(self.__permission_value)):
+ raise PermissionError(
+ "The handle does not have permission to allocate or free memory. "
+ "Open the process with PROCESS_VM_OPERATION (or PROCESS_ALL_ACCESS)."
+ )
+
def __require_read(self) -> None:
if not _can_read(self.__permission_value):
raise PermissionError(
@@ -299,3 +314,19 @@ def write_process_memory(
resolve_bufflength(pytype, bufflength),
value,
)
+
+ def allocate_memory(self, size: int, *, permission=None) -> int:
+ self.__require_open()
+ self.__require_operation()
+ address = AllocateMemory(self.__process_handle, size, permission)
+ self.__allocations[address] = size
+ return address
+
+ def free_memory(self, address: int, size: int = 0) -> bool:
+ self.__require_open()
+ self.__require_operation()
+ # MEM_RELEASE frees the whole allocation regardless of size; the
+ # tracked size is dropped on success for bookkeeping parity.
+ FreeMemory(self.__process_handle, address)
+ self.__allocations.pop(address, None)
+ return True
diff --git a/README.md b/README.md
index 89cac55..9ab7793 100644
--- a/README.md
+++ b/README.md
@@ -54,6 +54,7 @@ reading, writing and searching values in the process memory.
| **Pattern scan** | Byte signatures or regex — `grep` for process memory. |
| **Pointer chains** | Walk multi-level pointers (`[[base+0x10]+0x20]+0x30`) in one call. |
| **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`. |
@@ -333,6 +334,30 @@ hp = process.read_process_memory(hp_address, int, 4)
`ptr_size=4` for 32-bit targets, `ptr_size=8` (default) for 64-bit.
+### 🧱 Allocating memory in the target
+
+Reserve a fresh block inside the target process, write to it like any other
+address, then release it. The library remembers each allocation's size, so
+`free_memory(address)` works without you tracking it:
+
+```python
+address = process.allocate_memory(64) # base of a new 64-byte region
+process.write_process_memory(address, int, 4, 1337)
+process.free_memory(address) # release it
+```
+
+`allocate_memory` takes an optional, platform-specific `permission` (a `PAGE_*`
+value on Windows — default `PAGE_EXECUTE_READWRITE`; a `VM_PROT_*` bitmask on
+macOS — default read+write), mirroring `OpenProcess(permission=...)`.
+
+> [!NOTE]
+> **Linux is not supported here.** It has no syscall to allocate memory in
+> another process's address space (`mmap` only affects the calling process);
+> doing so would require a ptrace-based engine to make the target call `mmap`
+> itself. Both methods raise `NotImplementedError` on Linux. Windows
+> (`VirtualAllocEx`/`VirtualFreeEx`) and macOS
+> (`mach_vm_allocate`/`mach_vm_deallocate`) are fully supported.
+
---
## Platform Notes
diff --git a/assets/screenshots/app.png b/assets/screenshots/app.png
index ffe9d90..f77c197 100644
Binary files a/assets/screenshots/app.png and b/assets/screenshots/app.png differ
diff --git a/scripts/generate_app_screenshot.py b/scripts/generate_app_screenshot.py
index 9a7ccad..c83e1b4 100644
--- a/scripts/generate_app_screenshot.py
+++ b/scripts/generate_app_screenshot.py
@@ -70,7 +70,7 @@ def populate_results(window):
model._values[i] = cur
model.layoutChanged.emit()
- window._results_label.setText(f"Found {len(rows)} addresses (showing all).")
+ window._results_label.setText(f"Found {len(rows)} addresses.")
window._scanner.set_has_results(True)
diff --git a/tests/test_allocate_free.py b/tests/test_allocate_free.py
new file mode 100644
index 0000000..9a62505
--- /dev/null
+++ b/tests/test_allocate_free.py
@@ -0,0 +1,110 @@
+# -*- coding: utf-8 -*-
+
+"""
+Cross-platform tests for ``AbstractProcess.allocate_memory`` / ``free_memory``.
+
+The happy-path tests run against the test process itself (``os.getpid()``):
+allocate a region, round-trip values through it, then free it. Allocation in a
+remote process is a Windows/macOS capability; on Linux both methods raise
+``NotImplementedError`` (no cross-process allocation syscall), so the Linux
+build only asserts that contract.
+"""
+
+import os
+import sys
+
+import pytest
+
+if sys.platform not in ("win32", "darwin") and not sys.platform.startswith("linux"):
+ pytest.skip("Platform not supported by PyMemoryEditor", allow_module_level=True)
+
+
+from PyMemoryEditor import OpenProcess # noqa: E402
+
+
+IS_LINUX = sys.platform.startswith("linux")
+_unsupported = pytest.mark.skipif(
+ IS_LINUX, reason="allocate_memory/free_memory are unsupported on Linux"
+)
+
+
+@pytest.fixture
+def process():
+ """Open the test process itself and close it afterwards."""
+ with OpenProcess(pid=os.getpid()) as proc:
+ yield proc
+
+
+@_unsupported
+def test_allocate_returns_writable_region(process):
+ """A fresh allocation is non-zero and round-trips an int write/read."""
+ address = process.allocate_memory(64)
+ try:
+ assert isinstance(address, int)
+ assert address > 0
+
+ process.write_process_memory(address, int, 4, 0x1234ABCD)
+ value = process.read_process_memory(address, int, 4)
+ assert (value & 0xFFFFFFFF) == 0x1234ABCD
+ finally:
+ assert process.free_memory(address) is True
+
+
+@_unsupported
+def test_allocate_string_roundtrip(process):
+ """The allocated region holds arbitrary bytes (string round-trip)."""
+ address = process.allocate_memory(32)
+ try:
+ process.write_process_memory(address, str, 5, "hello")
+ assert process.read_process_memory(address, str, 5) == "hello"
+ finally:
+ process.free_memory(address)
+
+
+@_unsupported
+def test_free_without_size_uses_tracked_size(process):
+ """``free_memory(address)`` works without a size — the size is remembered."""
+ address = process.allocate_memory(128)
+ assert process.free_memory(address) is True
+
+
+@_unsupported
+def test_multiple_allocations_are_distinct(process):
+ """Separate allocations land at separate addresses and free independently."""
+ a = process.allocate_memory(64)
+ b = process.allocate_memory(64)
+ try:
+ assert a != b
+ finally:
+ assert process.free_memory(a) is True
+ assert process.free_memory(b) is True
+
+
+@_unsupported
+def test_allocate_rejects_nonpositive_size(process):
+ """A zero / negative size is rejected before any syscall."""
+ with pytest.raises(ValueError):
+ process.allocate_memory(0)
+ with pytest.raises(ValueError):
+ process.allocate_memory(-1)
+
+
+@pytest.mark.skipif(
+ sys.platform != "darwin",
+ reason="macOS needs the size to free; unknown address without size is a ValueError",
+)
+def test_macos_free_unknown_address_without_size_raises(process):
+ """On macOS, freeing an address we didn't allocate (no size) is a ValueError."""
+ with pytest.raises(ValueError):
+ process.free_memory(0x1000)
+
+
+@pytest.mark.skipif(
+ not IS_LINUX, reason="Linux-specific: allocation is not supported"
+)
+def test_linux_allocate_free_not_implemented(process):
+ """On Linux both methods must raise NotImplementedError, not silently no-op."""
+ with pytest.raises(NotImplementedError):
+ process.allocate_memory(64)
+ with pytest.raises(NotImplementedError):
+ process.free_memory(0x1000)