PyMemoryEditor 3.0.2__py3-none-any.whl → 3.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- PyMemoryEditor/__init__.py +1 -1
- PyMemoryEditor/app/_widgets.py +54 -26
- PyMemoryEditor/app/application.py +59 -6
- PyMemoryEditor/app/cheat_table.py +95 -23
- PyMemoryEditor/app/log_console_dialog.py +2 -2
- PyMemoryEditor/app/main_window.py +66 -16
- PyMemoryEditor/app/memory_map_dialog.py +4 -14
- PyMemoryEditor/app/memory_viewer_dialog.py +14 -15
- PyMemoryEditor/app/modules_dialog.py +2 -3
- PyMemoryEditor/app/open_process_dialog.py +2 -14
- PyMemoryEditor/app/pointer_chain_dialog.py +18 -16
- PyMemoryEditor/app/results_view.py +89 -15
- PyMemoryEditor/app/scan_worker.py +2 -1
- PyMemoryEditor/linux/functions.py +68 -8
- PyMemoryEditor/linux/process.py +4 -0
- PyMemoryEditor/macos/functions.py +51 -3
- PyMemoryEditor/macos/process.py +8 -0
- PyMemoryEditor/macos/types.py +5 -0
- PyMemoryEditor/mcp/config.py +41 -24
- PyMemoryEditor/mcp/policy.py +108 -16
- PyMemoryEditor/mcp/server.py +8 -0
- PyMemoryEditor/mcp/session.py +4 -17
- PyMemoryEditor/mcp/toolset.py +223 -172
- PyMemoryEditor/process/abstract.py +113 -14
- PyMemoryEditor/process/pointer_scan.py +43 -11
- PyMemoryEditor/process/region.py +1 -4
- PyMemoryEditor/process/remote_pointer.py +11 -3
- PyMemoryEditor/process/scanning.py +38 -33
- PyMemoryEditor/util/convert.py +51 -50
- PyMemoryEditor/util/scan.py +202 -78
- PyMemoryEditor/win32/functions.py +160 -14
- PyMemoryEditor/win32/process.py +58 -10
- {pymemoryeditor-3.0.2.dist-info → pymemoryeditor-3.1.0.dist-info}/METADATA +1 -1
- {pymemoryeditor-3.0.2.dist-info → pymemoryeditor-3.1.0.dist-info}/RECORD +37 -37
- {pymemoryeditor-3.0.2.dist-info → pymemoryeditor-3.1.0.dist-info}/WHEEL +0 -0
- {pymemoryeditor-3.0.2.dist-info → pymemoryeditor-3.1.0.dist-info}/entry_points.txt +0 -0
- {pymemoryeditor-3.0.2.dist-info → pymemoryeditor-3.1.0.dist-info}/licenses/LICENSE +0 -0
PyMemoryEditor/__init__.py
CHANGED
PyMemoryEditor/app/_widgets.py
CHANGED
|
@@ -166,45 +166,76 @@ class NumericItem(QStandardItem):
|
|
|
166
166
|
return self.text() < other.text()
|
|
167
167
|
|
|
168
168
|
|
|
169
|
+
_HEX_DIGITS = frozenset("0123456789abcdefABCDEF")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _parse_hex_digits(text: str) -> Optional[int]:
|
|
173
|
+
"""``text`` as an unsigned hex number, or None. Accepts a ``0x`` prefix."""
|
|
174
|
+
cleaned = text.strip()
|
|
175
|
+
if cleaned[:2].lower() == "0x":
|
|
176
|
+
cleaned = cleaned[2:]
|
|
177
|
+
# Explicit digit check rather than relying on int(): int(x, 16) also takes
|
|
178
|
+
# "1_0" (underscore separators) and leading sign/whitespace, none of which
|
|
179
|
+
# is a hex address a user meant to type.
|
|
180
|
+
if not cleaned or not _HEX_DIGITS.issuperset(cleaned):
|
|
181
|
+
return None
|
|
182
|
+
return int(cleaned, 16)
|
|
183
|
+
|
|
184
|
+
|
|
169
185
|
def parse_hex_address(text: str) -> Optional[int]:
|
|
170
186
|
"""Parse a hex address string (with or without 0x prefix) into an int.
|
|
171
187
|
|
|
172
|
-
|
|
188
|
+
Addresses are unsigned: a leading sign makes the string invalid. Returns
|
|
189
|
+
None on any parse error. Surrounding whitespace is tolerated.
|
|
173
190
|
"""
|
|
174
191
|
if not text:
|
|
175
192
|
return None
|
|
176
|
-
|
|
177
|
-
|
|
193
|
+
return _parse_hex_digits(text)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def parse_hex_offset(text: str) -> Optional[int]:
|
|
197
|
+
"""Parse a hex pointer-chain offset, which unlike an address may be negative.
|
|
198
|
+
|
|
199
|
+
``"-0x10"`` → -16, ``"10"`` → 16. Returns None on any parse error.
|
|
200
|
+
"""
|
|
201
|
+
if not text:
|
|
178
202
|
return None
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
203
|
+
cleaned = text.strip()
|
|
204
|
+
negative = cleaned.startswith("-")
|
|
205
|
+
if negative or cleaned.startswith("+"):
|
|
206
|
+
cleaned = cleaned[1:]
|
|
207
|
+
parsed = _parse_hex_digits(cleaned)
|
|
208
|
+
if parsed is None:
|
|
184
209
|
return None
|
|
210
|
+
return -parsed if negative else parsed
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def format_size(size: int) -> str:
|
|
214
|
+
"""Human-readable byte count (``1536`` → ``"1.5 KB"``), binary units."""
|
|
215
|
+
units = ["B", "KB", "MB", "GB", "TB"]
|
|
216
|
+
s = float(size)
|
|
217
|
+
for unit in units:
|
|
218
|
+
if s < 1024 or unit == units[-1]:
|
|
219
|
+
return f"{s:,.1f} {unit}" if unit != "B" else f"{int(s):,} B"
|
|
220
|
+
s /= 1024
|
|
221
|
+
return f"{size:,} B"
|
|
185
222
|
|
|
186
223
|
|
|
187
224
|
def parse_offsets(texts: Iterable[str]) -> Optional[List[int]]:
|
|
188
225
|
"""Parse pointer-chain offset tokens (in order) from raw field strings.
|
|
189
226
|
|
|
190
227
|
Empty tokens are skipped; every remaining token is read as hex (with or
|
|
191
|
-
without ``0x
|
|
192
|
-
the caller treats that as "invalid chain, do
|
|
193
|
-
can be unit-tested directly.
|
|
228
|
+
without ``0x``, optionally negative). Returns ``None`` if any non-empty
|
|
229
|
+
token can't be parsed — the caller treats that as "invalid chain, do
|
|
230
|
+
nothing". Pure (no Qt) so it can be unit-tested directly.
|
|
194
231
|
"""
|
|
195
232
|
offsets: List[int] = []
|
|
196
233
|
for text in texts:
|
|
197
234
|
if not text:
|
|
198
235
|
continue
|
|
199
|
-
parsed =
|
|
236
|
+
parsed = parse_hex_offset(text)
|
|
200
237
|
if parsed is None:
|
|
201
|
-
|
|
202
|
-
# int for ambiguous tokens like "10" (the dialog treats offsets as
|
|
203
|
-
# hex throughout).
|
|
204
|
-
try:
|
|
205
|
-
parsed = int(text, 16)
|
|
206
|
-
except ValueError:
|
|
207
|
-
return None
|
|
238
|
+
return None
|
|
208
239
|
offsets.append(parsed)
|
|
209
240
|
return offsets
|
|
210
241
|
|
|
@@ -230,13 +261,10 @@ def resolve_base_address(
|
|
|
230
261
|
module_name = name_part.strip().strip('"').strip("'").strip()
|
|
231
262
|
offset = parse_hex_address(offset_part)
|
|
232
263
|
if offset is None:
|
|
233
|
-
|
|
234
|
-
offset
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
"The offset after '+' must be hex "
|
|
238
|
-
'(e.g. "game.exe"+0x10F4F4).'
|
|
239
|
-
)
|
|
264
|
+
return None, (
|
|
265
|
+
"The offset after '+' must be hex "
|
|
266
|
+
'(e.g. "game.exe"+0x10F4F4).'
|
|
267
|
+
)
|
|
240
268
|
module_base = module_lookup(module_name)
|
|
241
269
|
if module_base is None:
|
|
242
270
|
return None, (
|
|
@@ -5,6 +5,7 @@ Entry point for the PyMemoryEditor App.
|
|
|
5
5
|
A Cheat-Engine-inspired memory scanner built on PySide6 (Qt for Python),
|
|
6
6
|
working on Windows, Linux and macOS.
|
|
7
7
|
"""
|
|
8
|
+
import logging
|
|
8
9
|
import sys
|
|
9
10
|
import signal
|
|
10
11
|
import contextlib
|
|
@@ -266,11 +267,6 @@ def apply_theme(app, theme_id: str = DEFAULT_THEME_ID) -> None:
|
|
|
266
267
|
)
|
|
267
268
|
|
|
268
269
|
|
|
269
|
-
def apply_dark_theme(app) -> None:
|
|
270
|
-
"""Backward-compatible shim that applies the default dark theme."""
|
|
271
|
-
apply_theme(app, DEFAULT_THEME_ID)
|
|
272
|
-
|
|
273
|
-
|
|
274
270
|
STYLE_SHEET = """
|
|
275
271
|
QToolTip {
|
|
276
272
|
color: %(text)s;
|
|
@@ -484,10 +480,66 @@ def _scoped_signal_handler(signalnum, handler):
|
|
|
484
480
|
signal.signal(signalnum, previous_handler)
|
|
485
481
|
|
|
486
482
|
|
|
483
|
+
_LOG = logging.getLogger(__name__)
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _qt_excepthook(exc_type, exc_value, exc_tb) -> None:
|
|
487
|
+
"""``sys.excepthook`` for the duration of the Qt event loop.
|
|
488
|
+
|
|
489
|
+
PySide6 catches an exception raised inside a slot, hands it to
|
|
490
|
+
``sys.excepthook`` and carries on — with the default hook that is a
|
|
491
|
+
traceback on stderr the user never sees, and the action they clicked
|
|
492
|
+
silently does nothing. Log it (so the Log Console gets it) and show it.
|
|
493
|
+
KeyboardInterrupt is left to the interpreter's hook: it is the host's
|
|
494
|
+
signal, not an app bug.
|
|
495
|
+
"""
|
|
496
|
+
if issubclass(exc_type, KeyboardInterrupt):
|
|
497
|
+
sys.__excepthook__(exc_type, exc_value, exc_tb)
|
|
498
|
+
return
|
|
499
|
+
_LOG.error(
|
|
500
|
+
"Unhandled exception in the UI", exc_info=(exc_type, exc_value, exc_tb)
|
|
501
|
+
)
|
|
502
|
+
try:
|
|
503
|
+
from PySide6.QtCore import QThread
|
|
504
|
+
from PySide6.QtWidgets import QApplication, QMessageBox
|
|
505
|
+
|
|
506
|
+
app = QApplication.instance()
|
|
507
|
+
# Widgets may only be created on the GUI thread. PySide6 routes an
|
|
508
|
+
# exception escaping a QThread.run() through this same hook, from
|
|
509
|
+
# that worker thread, so a dialog there would be undefined behaviour.
|
|
510
|
+
# The log line above is the whole report in that case.
|
|
511
|
+
if app is None or QThread.currentThread() != app.thread():
|
|
512
|
+
return
|
|
513
|
+
QMessageBox.critical(
|
|
514
|
+
None,
|
|
515
|
+
"Unexpected error",
|
|
516
|
+
"%s: %s\n\nThe full traceback is in Tools → Log Console."
|
|
517
|
+
% (exc_type.__name__, exc_value),
|
|
518
|
+
)
|
|
519
|
+
except Exception: # noqa: BLE001 — a hook must never raise
|
|
520
|
+
pass
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
@contextlib.contextmanager
|
|
524
|
+
def _scoped_excepthook(hook):
|
|
525
|
+
"""Install ``hook`` as ``sys.excepthook`` for the block, then restore."""
|
|
526
|
+
previous = sys.excepthook
|
|
527
|
+
sys.excepthook = hook
|
|
528
|
+
try:
|
|
529
|
+
yield
|
|
530
|
+
finally:
|
|
531
|
+
sys.excepthook = previous
|
|
532
|
+
|
|
533
|
+
|
|
487
534
|
def main(argv=None):
|
|
488
535
|
"""
|
|
489
536
|
Run the app. Safe to call in-process: it installs no signal handler.
|
|
490
537
|
|
|
538
|
+
While the event loop runs, ``sys.excepthook`` is swapped for one that logs
|
|
539
|
+
and shows slot exceptions (PySide6 routes them there and continues), and
|
|
540
|
+
restored on the way out — the loop owns the thread for that time, so an
|
|
541
|
+
embedder's own hook is not observable from inside it anyway.
|
|
542
|
+
|
|
491
543
|
``argv`` defaults to ``sys.argv`` so packaging tools (which call
|
|
492
544
|
``main()`` with no arguments) keep working. Tests and embedders can pass
|
|
493
545
|
an explicit list — previously a positional ``*args`` was accepted but
|
|
@@ -536,7 +588,8 @@ def main(argv=None):
|
|
|
536
588
|
window = MainWindow(process)
|
|
537
589
|
window.show()
|
|
538
590
|
try:
|
|
539
|
-
|
|
591
|
+
with _scoped_excepthook(_qt_excepthook):
|
|
592
|
+
app.exec()
|
|
540
593
|
finally:
|
|
541
594
|
try:
|
|
542
595
|
# `process` is only the handle the picker opened; File → Change
|
|
@@ -56,11 +56,6 @@ from .value_types import (
|
|
|
56
56
|
)
|
|
57
57
|
|
|
58
58
|
|
|
59
|
-
# Re-exported for backward compatibility with callers that imported the
|
|
60
|
-
# poll-interval constant from this module before the split.
|
|
61
|
-
_TICK_INTERVAL_MS = TICK_INTERVAL_MS
|
|
62
|
-
|
|
63
|
-
|
|
64
59
|
class CheatTable(QWidget):
|
|
65
60
|
"""Bottom pane: saved addresses, freezing, manual edits."""
|
|
66
61
|
|
|
@@ -80,6 +75,14 @@ class CheatTable(QWidget):
|
|
|
80
75
|
self._suspend_signals = False
|
|
81
76
|
# Before _build_ui(), which connects cellChanged — a path that reads it.
|
|
82
77
|
self._poller_stopped = False
|
|
78
|
+
# Set while the owner knows the target is gone (see set_polling_paused):
|
|
79
|
+
# the poller idles on an empty snapshot and edits are refused instead
|
|
80
|
+
# of being queued against a dead handle.
|
|
81
|
+
self._paused = False
|
|
82
|
+
# Write failures reported by the poller since the last flush. They are
|
|
83
|
+
# shown in one dialog per event-loop burst, so a bulk edit whose every
|
|
84
|
+
# row fails (target exited) produces one message, not one per row.
|
|
85
|
+
self._pending_write_failures: List[Tuple[int, str]] = []
|
|
83
86
|
|
|
84
87
|
self._build_ui()
|
|
85
88
|
|
|
@@ -130,6 +133,31 @@ class CheatTable(QWidget):
|
|
|
130
133
|
self.shutdown()
|
|
131
134
|
super().closeEvent(event)
|
|
132
135
|
|
|
136
|
+
def set_polling_paused(self, paused: bool) -> None:
|
|
137
|
+
"""Idle the poll loop while the target is known to be gone.
|
|
138
|
+
|
|
139
|
+
The owning window's heartbeat calls this when the process exits. The
|
|
140
|
+
publish timer stops and the worker is handed an empty snapshot, so it
|
|
141
|
+
sleeps through its ticks instead of hitting a dead handle ten times a
|
|
142
|
+
second (and, on a recycled PID, a stranger's memory). Entries are kept:
|
|
143
|
+
the table still shows the last values read. Resuming re-publishes.
|
|
144
|
+
"""
|
|
145
|
+
if self._poller_stopped or paused == self._paused:
|
|
146
|
+
return
|
|
147
|
+
self._paused = paused
|
|
148
|
+
if paused:
|
|
149
|
+
self._publish_timer.stop()
|
|
150
|
+
self._poller.update_snapshot([])
|
|
151
|
+
else:
|
|
152
|
+
self._publish_snapshot_to_worker()
|
|
153
|
+
self._publish_timer.start()
|
|
154
|
+
|
|
155
|
+
def is_polling_paused(self) -> bool:
|
|
156
|
+
return self._paused
|
|
157
|
+
|
|
158
|
+
def entry_count(self) -> int:
|
|
159
|
+
return len(self._entries)
|
|
160
|
+
|
|
133
161
|
def _build_ui(self) -> None:
|
|
134
162
|
layout = QVBoxLayout(self)
|
|
135
163
|
# Small top inset so the toolbar buttons don't sit flush against the
|
|
@@ -208,8 +236,17 @@ class CheatTable(QWidget):
|
|
|
208
236
|
# spotted from the table, so the floor is enforced once, at the door,
|
|
209
237
|
# rather than at each of those call sites. (The AOB pattern spec is the
|
|
210
238
|
# one whose declared length is 0 — the scanner derives its real width
|
|
211
|
-
# from the pattern.)
|
|
212
|
-
|
|
239
|
+
# from the pattern.) The ceiling is the same one every other width
|
|
240
|
+
# field enforces (MAX_ENTRY_LENGTH); only a JSON import can carry more,
|
|
241
|
+
# and silently resizing its entry would alter what the user exports
|
|
242
|
+
# next, so that is refused -- the import loop reports and skips it.
|
|
243
|
+
length = int(entry.length)
|
|
244
|
+
if length > MAX_ENTRY_LENGTH:
|
|
245
|
+
raise ValueError(
|
|
246
|
+
"entry at 0x%X has length %d, over the %d-byte maximum"
|
|
247
|
+
% (entry.address, length, MAX_ENTRY_LENGTH)
|
|
248
|
+
)
|
|
249
|
+
entry.length = max(1, length)
|
|
213
250
|
|
|
214
251
|
# If the address already exists, just refresh its description/type.
|
|
215
252
|
for existing in self._entries:
|
|
@@ -236,7 +273,16 @@ class CheatTable(QWidget):
|
|
|
236
273
|
length: int,
|
|
237
274
|
description: str = "",
|
|
238
275
|
) -> None:
|
|
239
|
-
"""Convenience used by the scanner panel to bulk-promote rows.
|
|
276
|
+
"""Convenience used by the scanner panel to bulk-promote rows.
|
|
277
|
+
|
|
278
|
+
:raises ValueError: if ``length`` is over ``MAX_ENTRY_LENGTH`` -- checked
|
|
279
|
+
once up front so a refusal adds none of the rows rather than some.
|
|
280
|
+
"""
|
|
281
|
+
if int(length) > MAX_ENTRY_LENGTH:
|
|
282
|
+
raise ValueError(
|
|
283
|
+
"length %d is over the %d-byte maximum for a cheat-table entry"
|
|
284
|
+
% (int(length), MAX_ENTRY_LENGTH)
|
|
285
|
+
)
|
|
240
286
|
for addr in addresses:
|
|
241
287
|
self.add_entry(
|
|
242
288
|
CheatEntry(
|
|
@@ -356,8 +402,9 @@ class CheatTable(QWidget):
|
|
|
356
402
|
# next poll tick reads the value back and corrects the cell if the
|
|
357
403
|
# write didn't take, and an outright failure returns via
|
|
358
404
|
# write_failed → _on_write_failed.
|
|
359
|
-
# Nothing to route it to once shutdown() handed the worker over
|
|
360
|
-
|
|
405
|
+
# Nothing to route it to once shutdown() handed the worker over,
|
|
406
|
+
# and nothing to write to while the target is known to be gone.
|
|
407
|
+
if self._poller_stopped or self._paused:
|
|
361
408
|
return
|
|
362
409
|
self._poller.request_write(
|
|
363
410
|
entry.address, entry.spec.pytype, entry.length, value
|
|
@@ -469,13 +516,33 @@ class CheatTable(QWidget):
|
|
|
469
516
|
def _on_write_failed(self, failure) -> None:
|
|
470
517
|
"""Surface a manual value write that failed on the worker thread.
|
|
471
518
|
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
519
|
+
Writes are async (queued onto the poll worker), so a failure arrives
|
|
520
|
+
here via the ``write_failed`` signal rather than inline. The optimistic
|
|
521
|
+
cell update made when the edit was queued is corrected by the next poll
|
|
522
|
+
tick, which reads back the unchanged value.
|
|
523
|
+
|
|
524
|
+
Failures are collected and shown by ``_flush_write_failures`` on the
|
|
525
|
+
next event-loop pass: the worker drains a bulk edit's queue in one
|
|
526
|
+
tick, so its failures land here back to back and read as one report.
|
|
476
527
|
"""
|
|
477
|
-
|
|
478
|
-
|
|
528
|
+
address, _pytype, _length, message = failure
|
|
529
|
+
if not self._pending_write_failures:
|
|
530
|
+
QTimer.singleShot(0, self._flush_write_failures)
|
|
531
|
+
self._pending_write_failures.append((address, message))
|
|
532
|
+
|
|
533
|
+
def _flush_write_failures(self) -> None:
|
|
534
|
+
failures = self._pending_write_failures
|
|
535
|
+
self._pending_write_failures = []
|
|
536
|
+
if not failures:
|
|
537
|
+
return
|
|
538
|
+
if len(failures) == 1:
|
|
539
|
+
QMessageBox.critical(self, "Write Failed", failures[0][1])
|
|
540
|
+
return
|
|
541
|
+
preview = "\n".join(f"0x{addr:X}: {msg}" for addr, msg in failures[:10])
|
|
542
|
+
extra = "" if len(failures) <= 10 else f"\n…and {len(failures) - 10} more."
|
|
543
|
+
QMessageBox.critical(
|
|
544
|
+
self, "Write Failed", f"{len(failures)} write(s) failed:\n\n{preview}{extra}"
|
|
545
|
+
)
|
|
479
546
|
|
|
480
547
|
def _editing_row(self) -> int:
|
|
481
548
|
"""Return the row currently being edited, or -1 if none."""
|
|
@@ -603,15 +670,20 @@ class CheatTable(QWidget):
|
|
|
603
670
|
if spec.accepts_length_override:
|
|
604
671
|
entry.length = effective_length
|
|
605
672
|
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
673
|
+
# Same path as an inline edit: the syscall runs on the poll
|
|
674
|
+
# worker, never on the UI thread (a slow target or a page
|
|
675
|
+
# fault would freeze the interface for every row). A write
|
|
676
|
+
# that fails comes back via write_failed → one coalesced
|
|
677
|
+
# dialog; the optimistic update below is corrected by the
|
|
678
|
+
# next tick's read-back.
|
|
679
|
+
if self._poller_stopped or self._paused:
|
|
611
680
|
failures.append(
|
|
612
|
-
(entry.address,
|
|
681
|
+
(entry.address, "the target process is no longer available")
|
|
613
682
|
)
|
|
614
683
|
continue
|
|
684
|
+
self._poller.request_write(
|
|
685
|
+
entry.address, spec.pytype, entry.length, value
|
|
686
|
+
)
|
|
615
687
|
|
|
616
688
|
entry.last_value = value
|
|
617
689
|
if entry.frozen:
|
|
@@ -873,7 +945,7 @@ def prompt_for_manual_entry(parent) -> Optional[CheatEntry]:
|
|
|
873
945
|
"Buffer length (bytes):",
|
|
874
946
|
value=spec.length or 4,
|
|
875
947
|
minValue=1,
|
|
876
|
-
maxValue=
|
|
948
|
+
maxValue=MAX_ENTRY_LENGTH,
|
|
877
949
|
)
|
|
878
950
|
if not ok:
|
|
879
951
|
return None
|
|
@@ -31,7 +31,7 @@ from PySide6.QtWidgets import (
|
|
|
31
31
|
QVBoxLayout,
|
|
32
32
|
)
|
|
33
33
|
|
|
34
|
-
from ._widgets import TearsDownOnClose
|
|
34
|
+
from ._widgets import MONOSPACE_FAMILY, TearsDownOnClose
|
|
35
35
|
|
|
36
36
|
|
|
37
37
|
_LOGGER_NAME = "PyMemoryEditor"
|
|
@@ -146,7 +146,7 @@ class LogConsoleDialog(TearsDownOnClose, QDialog):
|
|
|
146
146
|
|
|
147
147
|
self._console = QPlainTextEdit()
|
|
148
148
|
self._console.setReadOnly(True)
|
|
149
|
-
self._console.setFont(QFont(
|
|
149
|
+
self._console.setFont(QFont(MONOSPACE_FAMILY, 10))
|
|
150
150
|
self._console.setLineWrapMode(QPlainTextEdit.NoWrap)
|
|
151
151
|
# Cap the buffer so a long-running auto-scan doesn't grow the dialog
|
|
152
152
|
# memory unboundedly. ~5000 lines is plenty for live debugging.
|
|
@@ -78,11 +78,19 @@ class MainWindow(QMainWindow):
|
|
|
78
78
|
|
|
79
79
|
closing = Signal()
|
|
80
80
|
|
|
81
|
+
# Ask before closing a window whose cheat table has entries. Tests that
|
|
82
|
+
# build and tear down windows set this to False on the class.
|
|
83
|
+
confirm_close_with_entries = True
|
|
84
|
+
|
|
81
85
|
def __init__(self, process: AbstractProcess):
|
|
82
86
|
super().__init__()
|
|
83
87
|
self._process = process
|
|
84
88
|
self._worker: Optional[Union[FirstScanWorker, RefineScanWorker]] = None
|
|
85
89
|
self._region_snapshot: Optional[list] = None
|
|
90
|
+
# Set by the heartbeat once the target is gone. Everything that would
|
|
91
|
+
# re-enable the scanner (a worker finishing, in particular) checks it,
|
|
92
|
+
# so the lock _check_process_alive takes holds until Change Process.
|
|
93
|
+
self._target_dead = False
|
|
86
94
|
self._memory_map: Optional[MemoryMapDialog] = None
|
|
87
95
|
self._hex_viewers: List[MemoryViewerDialog] = []
|
|
88
96
|
|
|
@@ -242,12 +250,14 @@ class MainWindow(QMainWindow):
|
|
|
242
250
|
|
|
243
251
|
tools_menu = menu_bar.addMenu("&Tools")
|
|
244
252
|
memory_map_action = QAction("Memory Map…", self)
|
|
245
|
-
|
|
253
|
+
# Not Ctrl+M / Ctrl+H: Qt maps Ctrl to Cmd on macOS, where those are
|
|
254
|
+
# the system-wide Minimize and Hide shortcuts.
|
|
255
|
+
memory_map_action.setShortcut(QKeySequence("Ctrl+R"))
|
|
246
256
|
memory_map_action.triggered.connect(self._open_memory_map)
|
|
247
257
|
tools_menu.addAction(memory_map_action)
|
|
248
258
|
|
|
249
259
|
hex_viewer_action = QAction("Hex Viewer…", self)
|
|
250
|
-
hex_viewer_action.setShortcut(QKeySequence("Ctrl+H"))
|
|
260
|
+
hex_viewer_action.setShortcut(QKeySequence("Ctrl+Shift+H"))
|
|
251
261
|
hex_viewer_action.triggered.connect(lambda: self._open_hex_viewer(0))
|
|
252
262
|
tools_menu.addAction(hex_viewer_action)
|
|
253
263
|
|
|
@@ -520,7 +530,11 @@ class MainWindow(QMainWindow):
|
|
|
520
530
|
|
|
521
531
|
def _cleanup_worker(self) -> None:
|
|
522
532
|
self._worker = None
|
|
523
|
-
|
|
533
|
+
# A scan that was running when the target exited finishes (with an
|
|
534
|
+
# error) after the heartbeat locked the scanner; releasing the lock
|
|
535
|
+
# here would hand the controls back against a dead handle.
|
|
536
|
+
if not self._target_dead:
|
|
537
|
+
self._set_busy(False)
|
|
524
538
|
|
|
525
539
|
def _set_busy(self, busy: bool) -> None:
|
|
526
540
|
self._scanner.set_busy(busy)
|
|
@@ -529,7 +543,13 @@ class MainWindow(QMainWindow):
|
|
|
529
543
|
if not addresses:
|
|
530
544
|
return
|
|
531
545
|
spec, length = self._scanner.current_spec_and_length()
|
|
532
|
-
|
|
546
|
+
try:
|
|
547
|
+
self._cheat.add_addresses(addresses, spec, length, description="")
|
|
548
|
+
except ValueError as exc:
|
|
549
|
+
# The scanner's width field allows more than the cheat table's
|
|
550
|
+
# MAX_ENTRY_LENGTH; add_entry refuses such a width.
|
|
551
|
+
QMessageBox.warning(self, "Cheat Table", str(exc))
|
|
552
|
+
return
|
|
533
553
|
self._status.showMessage(f"Added {len(addresses)} address(es) to cheat table.")
|
|
534
554
|
|
|
535
555
|
def _open_memory_map(self) -> None:
|
|
@@ -609,15 +629,25 @@ class MainWindow(QMainWindow):
|
|
|
609
629
|
self, address: int, spec_label: str, length: int
|
|
610
630
|
) -> None:
|
|
611
631
|
"""Promote a resolved pointer-chain address into the cheat table."""
|
|
632
|
+
self._promote_resolved_address(address, spec_label, length, "pointer chain")
|
|
633
|
+
|
|
634
|
+
def _promote_resolved_address(
|
|
635
|
+
self, address: int, spec_label: str, length: int, source: str
|
|
636
|
+
) -> None:
|
|
637
|
+
"""Add an address resolved by a pointer tool to the cheat table."""
|
|
612
638
|
entry = CheatEntry(
|
|
613
639
|
description="",
|
|
614
640
|
address=int(address),
|
|
615
641
|
spec_label=spec_label,
|
|
616
642
|
length=int(length),
|
|
617
643
|
)
|
|
618
|
-
|
|
644
|
+
try:
|
|
645
|
+
self._cheat.add_entry(entry)
|
|
646
|
+
except ValueError as exc:
|
|
647
|
+
QMessageBox.warning(self, "Cheat Table", str(exc))
|
|
648
|
+
return
|
|
619
649
|
self._status.showMessage(
|
|
620
|
-
f"Added 0x{address:X} to cheat table (from
|
|
650
|
+
f"Added 0x{address:X} to cheat table (from {source})."
|
|
621
651
|
)
|
|
622
652
|
|
|
623
653
|
def _open_pointer_scan_dialog(self, address: int = 0) -> None:
|
|
@@ -651,16 +681,7 @@ class MainWindow(QMainWindow):
|
|
|
651
681
|
self, address: int, spec_label: str, length: int
|
|
652
682
|
) -> None:
|
|
653
683
|
"""Promote a resolved pointer-scan path's address into the cheat table."""
|
|
654
|
-
|
|
655
|
-
description="",
|
|
656
|
-
address=int(address),
|
|
657
|
-
spec_label=spec_label,
|
|
658
|
-
length=int(length),
|
|
659
|
-
)
|
|
660
|
-
self._cheat.add_entry(entry)
|
|
661
|
-
self._status.showMessage(
|
|
662
|
-
f"Added 0x{address:X} to cheat table (from pointer scan)."
|
|
663
|
-
)
|
|
684
|
+
self._promote_resolved_address(address, spec_label, length, "pointer scan")
|
|
664
685
|
|
|
665
686
|
def _open_log_console(self) -> None:
|
|
666
687
|
if self._log_console_dialog is None:
|
|
@@ -809,7 +830,11 @@ class MainWindow(QMainWindow):
|
|
|
809
830
|
def _check_process_alive(self) -> None:
|
|
810
831
|
if not self._target_is_alive():
|
|
811
832
|
self._heartbeat.stop()
|
|
833
|
+
self._target_dead = True
|
|
812
834
|
self._scanner.set_busy(True)
|
|
835
|
+
# The cheat poller would otherwise keep reading (and, for frozen
|
|
836
|
+
# rows, writing) the dead handle ten times a second.
|
|
837
|
+
self._cheat.set_polling_paused(True)
|
|
813
838
|
self._status.showMessage("Target process exited — operations disabled.")
|
|
814
839
|
QMessageBox.warning(
|
|
815
840
|
self,
|
|
@@ -845,6 +870,7 @@ class MainWindow(QMainWindow):
|
|
|
845
870
|
# If the previous target exited, _check_process_alive locked the scanner
|
|
846
871
|
# via set_busy(True). Switching to a live process must release that lock,
|
|
847
872
|
# otherwise the scan controls stay disabled against the new target.
|
|
873
|
+
self._target_dead = False
|
|
848
874
|
self._scanner.set_busy(False)
|
|
849
875
|
|
|
850
876
|
# Tear down auxiliary dialogs that hold a reference to the old
|
|
@@ -904,6 +930,10 @@ class MainWindow(QMainWindow):
|
|
|
904
930
|
self._status.showMessage(f"Now targeting PID {self._process.pid}.")
|
|
905
931
|
|
|
906
932
|
def closeEvent(self, event: QCloseEvent) -> None:
|
|
933
|
+
if not self._confirm_discard_cheat_table():
|
|
934
|
+
event.ignore()
|
|
935
|
+
return
|
|
936
|
+
|
|
907
937
|
# Stop the heartbeat *first*: _shutdown_worker pumps the event loop
|
|
908
938
|
# below, and a heartbeat firing during that pump could pop a modal
|
|
909
939
|
# dialog mid-teardown.
|
|
@@ -943,6 +973,26 @@ class MainWindow(QMainWindow):
|
|
|
943
973
|
self.closing.emit()
|
|
944
974
|
super().closeEvent(event)
|
|
945
975
|
|
|
976
|
+
def _confirm_discard_cheat_table(self) -> bool:
|
|
977
|
+
"""Ask before quitting with unsaved cheat entries. True = go ahead.
|
|
978
|
+
|
|
979
|
+
The table is not persisted between runs (Export… is the way to keep
|
|
980
|
+
it), so a stray Cmd+Q / window close used to throw away every saved
|
|
981
|
+
address silently.
|
|
982
|
+
"""
|
|
983
|
+
count = self._cheat.entry_count()
|
|
984
|
+
if count == 0 or not self.confirm_close_with_entries:
|
|
985
|
+
return True
|
|
986
|
+
answer = QMessageBox.question(
|
|
987
|
+
self,
|
|
988
|
+
"Quit",
|
|
989
|
+
f"The cheat table has {count} entr{'y' if count == 1 else 'ies'} "
|
|
990
|
+
"that will be lost (use Export… to keep them).\n\nQuit anyway?",
|
|
991
|
+
QMessageBox.StandardButton.Discard | QMessageBox.StandardButton.Cancel,
|
|
992
|
+
QMessageBox.StandardButton.Cancel,
|
|
993
|
+
)
|
|
994
|
+
return answer == QMessageBox.StandardButton.Discard
|
|
995
|
+
|
|
946
996
|
def _shutdown_worker(self) -> None:
|
|
947
997
|
"""
|
|
948
998
|
Stop the active scan worker safely.
|
|
@@ -37,17 +37,7 @@ from PySide6.QtWidgets import (
|
|
|
37
37
|
from PyMemoryEditor import AbstractProcess, MemoryRegion, MemoryRegionSnapshot
|
|
38
38
|
|
|
39
39
|
from ._auto_refresh_dialog import AutoRefreshTableDialog
|
|
40
|
-
from ._widgets import MONOSPACE_FAMILY, NumericItem
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
def _format_size(size: int) -> str:
|
|
44
|
-
units = ["B", "KB", "MB", "GB", "TB"]
|
|
45
|
-
s = float(size)
|
|
46
|
-
for unit in units:
|
|
47
|
-
if s < 1024 or unit == units[-1]:
|
|
48
|
-
return f"{s:,.1f} {unit}" if unit != "B" else f"{int(s):,} B"
|
|
49
|
-
s /= 1024
|
|
50
|
-
return f"{size:,} B"
|
|
40
|
+
from ._widgets import MONOSPACE_FAMILY, NumericItem, format_size
|
|
51
41
|
|
|
52
42
|
|
|
53
43
|
# Unit multipliers for the allocate size selector (binary, 1 KB = 1024 B).
|
|
@@ -396,7 +386,7 @@ class MemoryMapDialog(AutoRefreshTableDialog):
|
|
|
396
386
|
addr_item.setFont(mono_font)
|
|
397
387
|
addr_item.setData(addr, Qt.UserRole)
|
|
398
388
|
|
|
399
|
-
size_item = NumericItem(
|
|
389
|
+
size_item = NumericItem(format_size(size))
|
|
400
390
|
size_item.setData(size, Qt.UserRole)
|
|
401
391
|
size_item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
|
402
392
|
|
|
@@ -416,12 +406,12 @@ class MemoryMapDialog(AutoRefreshTableDialog):
|
|
|
416
406
|
if needle:
|
|
417
407
|
count_text = (
|
|
418
408
|
f"{shown:,} of {total:,} regions shown · "
|
|
419
|
-
f"{
|
|
409
|
+
f"{format_size(total_bytes)} mapped"
|
|
420
410
|
)
|
|
421
411
|
else:
|
|
422
412
|
count_text = (
|
|
423
413
|
f"{total:,} regions · "
|
|
424
|
-
f"{
|
|
414
|
+
f"{format_size(total_bytes)} of virtual address space mapped"
|
|
425
415
|
)
|
|
426
416
|
# A filter keystroke rebuilds this line long after the poll gave up.
|
|
427
417
|
if self._polling_gave_up:
|