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)