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.
Files changed (37) hide show
  1. PyMemoryEditor/__init__.py +1 -1
  2. PyMemoryEditor/app/_widgets.py +54 -26
  3. PyMemoryEditor/app/application.py +59 -6
  4. PyMemoryEditor/app/cheat_table.py +95 -23
  5. PyMemoryEditor/app/log_console_dialog.py +2 -2
  6. PyMemoryEditor/app/main_window.py +66 -16
  7. PyMemoryEditor/app/memory_map_dialog.py +4 -14
  8. PyMemoryEditor/app/memory_viewer_dialog.py +14 -15
  9. PyMemoryEditor/app/modules_dialog.py +2 -3
  10. PyMemoryEditor/app/open_process_dialog.py +2 -14
  11. PyMemoryEditor/app/pointer_chain_dialog.py +18 -16
  12. PyMemoryEditor/app/results_view.py +89 -15
  13. PyMemoryEditor/app/scan_worker.py +2 -1
  14. PyMemoryEditor/linux/functions.py +68 -8
  15. PyMemoryEditor/linux/process.py +4 -0
  16. PyMemoryEditor/macos/functions.py +51 -3
  17. PyMemoryEditor/macos/process.py +8 -0
  18. PyMemoryEditor/macos/types.py +5 -0
  19. PyMemoryEditor/mcp/config.py +41 -24
  20. PyMemoryEditor/mcp/policy.py +108 -16
  21. PyMemoryEditor/mcp/server.py +8 -0
  22. PyMemoryEditor/mcp/session.py +4 -17
  23. PyMemoryEditor/mcp/toolset.py +223 -172
  24. PyMemoryEditor/process/abstract.py +113 -14
  25. PyMemoryEditor/process/pointer_scan.py +43 -11
  26. PyMemoryEditor/process/region.py +1 -4
  27. PyMemoryEditor/process/remote_pointer.py +11 -3
  28. PyMemoryEditor/process/scanning.py +38 -33
  29. PyMemoryEditor/util/convert.py +51 -50
  30. PyMemoryEditor/util/scan.py +202 -78
  31. PyMemoryEditor/win32/functions.py +160 -14
  32. PyMemoryEditor/win32/process.py +58 -10
  33. {pymemoryeditor-3.0.2.dist-info → pymemoryeditor-3.1.0.dist-info}/METADATA +1 -1
  34. {pymemoryeditor-3.0.2.dist-info → pymemoryeditor-3.1.0.dist-info}/RECORD +37 -37
  35. {pymemoryeditor-3.0.2.dist-info → pymemoryeditor-3.1.0.dist-info}/WHEEL +0 -0
  36. {pymemoryeditor-3.0.2.dist-info → pymemoryeditor-3.1.0.dist-info}/entry_points.txt +0 -0
  37. {pymemoryeditor-3.0.2.dist-info → pymemoryeditor-3.1.0.dist-info}/licenses/LICENSE +0 -0
@@ -8,7 +8,7 @@ Supported platforms: Windows, Linux and macOS (32-bit and 64-bit).
8
8
  """
9
9
 
10
10
  __author__ = "Jean Loui Bernard Silva de Jesus"
11
- __version__ = "3.0.2"
11
+ __version__ = "3.1.0"
12
12
 
13
13
  import logging
14
14
  import sys
@@ -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
- Returns None on any parse error. Whitespace is tolerated.
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
- cleaned = text.strip()
177
- if not cleaned:
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
- if cleaned.lower().startswith("0x"):
180
- cleaned = cleaned[2:]
181
- try:
182
- return int(cleaned, 16)
183
- except (TypeError, ValueError):
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``). Returns ``None`` if any non-empty token can't be parsed —
192
- the caller treats that as "invalid chain, do nothing". Pure (no Qt) so it
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 = parse_hex_address(text)
236
+ parsed = parse_hex_offset(text)
200
237
  if parsed is None:
201
- # parse_hex_address handles the 0x form; fall back to a plain hex
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
- try:
234
- offset = int(offset_part.strip(), 16)
235
- except ValueError:
236
- return None, (
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
- app.exec()
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
- entry.length = max(1, int(entry.length))
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
- if self._poller_stopped:
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
- The write is now async (queued onto the poll worker), so its failure
473
- arrives here via the ``write_failed`` signal rather than inline. The
474
- optimistic cell update made when the edit was queued is corrected by the
475
- next poll tick, which reads back the unchanged value.
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
- _address, _pytype, _length, message = failure
478
- QMessageBox.critical(self, "Write Failed", message)
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
- try:
607
- self._process.write_process_memory(
608
- entry.address, spec.pytype, entry.length, value
609
- )
610
- except Exception as exc: # noqa: BLE001
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, f"{type(exc).__name__}: {exc}")
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=1024,
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("Menlo, Consolas, Courier New", 10))
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
- memory_map_action.setShortcut(QKeySequence("Ctrl+M"))
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
- self._set_busy(False)
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
- self._cheat.add_addresses(addresses, spec, length, description="")
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
- self._cheat.add_entry(entry)
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 pointer chain)."
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
- entry = CheatEntry(
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(_format_size(size))
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"{_format_size(total_bytes)} mapped"
409
+ f"{format_size(total_bytes)} mapped"
420
410
  )
421
411
  else:
422
412
  count_text = (
423
413
  f"{total:,} regions · "
424
- f"{_format_size(total_bytes)} of virtual address space mapped"
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: