PyMemoryEditor 3.0.2__py3-none-any.whl → 3.2.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 (45) hide show
  1. PyMemoryEditor/__init__.py +1 -1
  2. PyMemoryEditor/app/_widgets.py +106 -27
  3. PyMemoryEditor/app/application.py +72 -12
  4. PyMemoryEditor/app/cheat_table.py +95 -23
  5. PyMemoryEditor/app/log_console_dialog.py +2 -2
  6. PyMemoryEditor/app/main_window.py +79 -24
  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/pointer_scan_dialog.py +24 -25
  13. PyMemoryEditor/app/results_view.py +89 -15
  14. PyMemoryEditor/app/scan_worker.py +2 -1
  15. PyMemoryEditor/linux/functions.py +177 -10
  16. PyMemoryEditor/linux/process.py +100 -12
  17. PyMemoryEditor/macos/functions.py +416 -166
  18. PyMemoryEditor/macos/libsystem.py +15 -0
  19. PyMemoryEditor/macos/process.py +8 -0
  20. PyMemoryEditor/macos/types.py +52 -1
  21. PyMemoryEditor/mcp/__init__.py +4 -2
  22. PyMemoryEditor/mcp/config.py +58 -34
  23. PyMemoryEditor/mcp/policy.py +108 -16
  24. PyMemoryEditor/mcp/server.py +48 -22
  25. PyMemoryEditor/mcp/session.py +227 -58
  26. PyMemoryEditor/mcp/toolset.py +435 -233
  27. PyMemoryEditor/process/abstract.py +136 -33
  28. PyMemoryEditor/process/pointer_scan.py +130 -19
  29. PyMemoryEditor/process/region.py +1 -4
  30. PyMemoryEditor/process/remote_pointer.py +11 -3
  31. PyMemoryEditor/process/scanning.py +241 -122
  32. PyMemoryEditor/process/thread_info.py +6 -4
  33. PyMemoryEditor/util/convert.py +51 -50
  34. PyMemoryEditor/util/pattern.py +38 -6
  35. PyMemoryEditor/util/scan.py +211 -80
  36. PyMemoryEditor/util/scan_numpy.py +50 -26
  37. PyMemoryEditor/win32/functions.py +280 -87
  38. PyMemoryEditor/win32/process.py +70 -12
  39. PyMemoryEditor/win32/types.py +23 -16
  40. {pymemoryeditor-3.0.2.dist-info → pymemoryeditor-3.2.0.dist-info}/METADATA +3 -1
  41. pymemoryeditor-3.2.0.dist-info/RECORD +73 -0
  42. pymemoryeditor-3.0.2.dist-info/RECORD +0 -73
  43. {pymemoryeditor-3.0.2.dist-info → pymemoryeditor-3.2.0.dist-info}/WHEEL +0 -0
  44. {pymemoryeditor-3.0.2.dist-info → pymemoryeditor-3.2.0.dist-info}/entry_points.txt +0 -0
  45. {pymemoryeditor-3.0.2.dist-info → pymemoryeditor-3.2.0.dist-info}/licenses/LICENSE +0 -0
@@ -46,7 +46,7 @@ from PySide6.QtWidgets import (
46
46
  from PyMemoryEditor import AbstractProcess, __version__
47
47
 
48
48
  from ._icon import app_icon
49
- from ._widgets import detach_worker
49
+ from ._widgets import detach_worker, run_after_detached_workers
50
50
  from .application import DEFAULT_THEME_ID, THEMES, apply_theme
51
51
  from .cheat_entry import CheatEntry
52
52
  from .cheat_table import CheatTable
@@ -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
@@ -884,13 +910,11 @@ class MainWindow(QMainWindow):
884
910
  old_cheat.shutdown()
885
911
  except Exception:
886
912
  pass
887
- # Accepted window: a worker that blew its join is detached, not
888
- # stopped, so it can still be inside a syscall here. The cheat poller
889
- # writes, which is why it checks the stop flag per entry.
890
- try:
891
- old_process.close()
892
- except Exception:
893
- pass
913
+ # A worker that blew its join is detached, not stopped, so it can still
914
+ # be inside a syscall on the old handle. Release the handle only once
915
+ # every detached worker has finished; closing it now could hand its
916
+ # value to an unrelated object while the cheat poller is mid-write.
917
+ run_after_detached_workers(lambda p=old_process: _close_quietly(p))
894
918
  old_index = self._right_splitter.indexOf(old_cheat)
895
919
  self._cheat = CheatTable(self._process)
896
920
  self._cheat.pointer_scan_for_address.connect(self._open_pointer_scan_dialog)
@@ -904,6 +928,10 @@ class MainWindow(QMainWindow):
904
928
  self._status.showMessage(f"Now targeting PID {self._process.pid}.")
905
929
 
906
930
  def closeEvent(self, event: QCloseEvent) -> None:
931
+ if not self._confirm_discard_cheat_table():
932
+ event.ignore()
933
+ return
934
+
907
935
  # Stop the heartbeat *first*: _shutdown_worker pumps the event loop
908
936
  # below, and a heartbeat firing during that pump could pop a modal
909
937
  # dialog mid-teardown.
@@ -943,6 +971,26 @@ class MainWindow(QMainWindow):
943
971
  self.closing.emit()
944
972
  super().closeEvent(event)
945
973
 
974
+ def _confirm_discard_cheat_table(self) -> bool:
975
+ """Ask before quitting with unsaved cheat entries. True = go ahead.
976
+
977
+ The table is not persisted between runs (Export… is the way to keep
978
+ it), so a stray Cmd+Q / window close used to throw away every saved
979
+ address silently.
980
+ """
981
+ count = self._cheat.entry_count()
982
+ if count == 0 or not self.confirm_close_with_entries:
983
+ return True
984
+ answer = QMessageBox.question(
985
+ self,
986
+ "Quit",
987
+ f"The cheat table has {count} entr{'y' if count == 1 else 'ies'} "
988
+ "that will be lost (use Export… to keep them).\n\nQuit anyway?",
989
+ QMessageBox.StandardButton.Discard | QMessageBox.StandardButton.Cancel,
990
+ QMessageBox.StandardButton.Cancel,
991
+ )
992
+ return answer == QMessageBox.StandardButton.Discard
993
+
946
994
  def _shutdown_worker(self) -> None:
947
995
  """
948
996
  Stop the active scan worker safely.
@@ -999,6 +1047,13 @@ class MainWindow(QMainWindow):
999
1047
  worker.deleteLater()
1000
1048
 
1001
1049
 
1050
+ def _close_quietly(process) -> None:
1051
+ try:
1052
+ process.close()
1053
+ except Exception: # noqa: BLE001 — the target may already be gone
1054
+ pass
1055
+
1056
+
1002
1057
  def _safe_for_json(value) -> object:
1003
1058
  if value is None or isinstance(value, (str, int, float, bool)):
1004
1059
  return value
@@ -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:
@@ -25,7 +25,12 @@ from PySide6.QtWidgets import (
25
25
  from PyMemoryEditor import AbstractProcess
26
26
 
27
27
  from ._auto_refresh_dialog import _FAILURE_GRACE_MS, _DataWorker
28
- from ._widgets import TearsDownOnClose, parse_hex_address, shutdown_worker_thread
28
+ from ._widgets import (
29
+ MONOSPACE_FAMILY,
30
+ TearsDownOnClose,
31
+ parse_hex_address,
32
+ shutdown_worker_thread,
33
+ )
29
34
 
30
35
 
31
36
  # Child of the "PyMemoryEditor" logger, so the Log Console (which attaches a
@@ -125,7 +130,7 @@ class MemoryViewerDialog(TearsDownOnClose, QDialog):
125
130
 
126
131
  self._dump = QPlainTextEdit()
127
132
  self._dump.setReadOnly(True)
128
- self._dump.setFont(QFont("Menlo, Consolas, Courier New", 11))
133
+ self._dump.setFont(QFont(MONOSPACE_FAMILY, 11))
129
134
  self._dump.setLineWrapMode(QPlainTextEdit.NoWrap)
130
135
  layout.addWidget(self._dump, 1)
131
136
 
@@ -135,7 +140,7 @@ class MemoryViewerDialog(TearsDownOnClose, QDialog):
135
140
  )
136
141
  self._write_edit = QLineEdit()
137
142
  self._write_edit.setPlaceholderText("e.g. DE AD BE EF")
138
- self._write_edit.setFont(QFont("Menlo, Consolas, Courier New", 11))
143
+ self._write_edit.setFont(QFont(MONOSPACE_FAMILY, 11))
139
144
  edit_row.addWidget(self._write_edit, 1)
140
145
  layout.addLayout(edit_row)
141
146
 
@@ -157,15 +162,10 @@ class MemoryViewerDialog(TearsDownOnClose, QDialog):
157
162
  text = self._addr_edit.text().strip()
158
163
  if not text:
159
164
  return None
160
- # Try hex first (with or without `0x`); fall back to decimal so callers
161
- # that paste a plain integer still work.
162
- addr = parse_hex_address(text)
163
- if addr is not None:
164
- return addr
165
- try:
166
- return int(text)
167
- except (TypeError, ValueError):
168
- return None
165
+ # Hex, with or without `0x` — the field is hex throughout, like every
166
+ # other address field in the app. (A decimal fallback here could never
167
+ # run: every decimal string is also a valid hex string.)
168
+ return parse_hex_address(text)
169
169
 
170
170
  def refresh(self) -> None:
171
171
  # Skip if a read is still running. The auto-refresh timer fires this
@@ -273,9 +273,8 @@ class MemoryViewerDialog(TearsDownOnClose, QDialog):
273
273
 
274
274
  def _sync_timer(self) -> None:
275
275
  self._timer.setInterval(int(self._interval_spin.value()))
276
- if self._auto_btn.isChecked() and not self._timer.isActive():
277
- self._timer.start()
278
- elif self._auto_btn.isChecked():
276
+ # start() on a running QTimer restarts it at the new interval.
277
+ if self._auto_btn.isChecked():
279
278
  self._timer.start()
280
279
 
281
280
  def _write_bytes(self) -> None:
@@ -38,8 +38,7 @@ from PySide6.QtWidgets import (
38
38
  from PyMemoryEditor import AbstractProcess, ModuleInfo
39
39
 
40
40
  from ._auto_refresh_dialog import AutoRefreshTableDialog
41
- from ._widgets import MONOSPACE_FAMILY, NumericItem
42
- from .memory_map_dialog import _format_size
41
+ from ._widgets import MONOSPACE_FAMILY, NumericItem, format_size
43
42
 
44
43
 
45
44
  class ModulesDialog(AutoRefreshTableDialog):
@@ -173,7 +172,7 @@ class ModulesDialog(AutoRefreshTableDialog):
173
172
  base_item.setData(base, Qt.UserRole)
174
173
 
175
174
  size = int(module.size)
176
- size_item = NumericItem(_format_size(size))
175
+ size_item = NumericItem(format_size(size))
177
176
  size_item.setData(size, Qt.UserRole)
178
177
  size_item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
179
178
 
@@ -40,7 +40,7 @@ from PyMemoryEditor import (
40
40
  )
41
41
 
42
42
  from ._icon import app_icon
43
- from ._widgets import NumericItem, TearsDownOnClose, shutdown_worker_thread
43
+ from ._widgets import NumericItem, TearsDownOnClose, format_size, shutdown_worker_thread
44
44
 
45
45
 
46
46
  if sys.platform == "win32":
@@ -88,18 +88,6 @@ def _open_kwargs():
88
88
  return {"permission": _APP_PERMISSION} if _APP_PERMISSION is not None else {}
89
89
 
90
90
 
91
- def _human_kb(size_bytes: int) -> str:
92
- if size_bytes < 1024:
93
- return f"{size_bytes} B"
94
- units = ["KB", "MB", "GB", "TB"]
95
- n = float(size_bytes)
96
- for unit in units:
97
- n /= 1024
98
- if n < 1024:
99
- return f"{n:,.1f} {unit}"
100
- return f"{n:,.1f} PB"
101
-
102
-
103
91
  # How long the auto-refresh waits between process-list re-enumerations.
104
92
  _REFRESH_INTERVAL_MS = 3000
105
93
 
@@ -314,7 +302,7 @@ class OpenProcessDialog(TearsDownOnClose, QDialog):
314
302
  name_item = QStandardItem(name)
315
303
  name_item.setData(pid, Qt.UserRole)
316
304
 
317
- mem_item = NumericItem(_human_kb(mem) if mem else "—")
305
+ mem_item = NumericItem(format_size(mem) if mem else "—")
318
306
  mem_item.setData(mem, Qt.UserRole)
319
307
  mem_item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
320
308
 
@@ -46,7 +46,12 @@ from PySide6.QtWidgets import (
46
46
 
47
47
  from PyMemoryEditor import AbstractProcess
48
48
 
49
- from ._widgets import parse_hex_address, parse_offsets, resolve_base_address
49
+ from ._widgets import (
50
+ MONOSPACE_FAMILY,
51
+ parse_hex_offset,
52
+ parse_offsets,
53
+ resolve_base_address,
54
+ )
50
55
  from .value_types import (
51
56
  VALUE_TYPES,
52
57
  ValueTypeSpec,
@@ -84,7 +89,7 @@ class _OffsetField(QWidget):
84
89
  self.edit = QLineEdit()
85
90
  self.edit.setPlaceholderText("0")
86
91
  self.edit.setFixedWidth(80)
87
- self.edit.setFont(QFont("Menlo, Consolas, Courier New", 10))
92
+ self.edit.setFont(QFont(MONOSPACE_FAMILY, 10))
88
93
  layout.addWidget(self.edit)
89
94
 
90
95
  suffix = QLabel("]")
@@ -159,7 +164,7 @@ class PointerChainDialog(QDialog):
159
164
  self._module_offset_edit.setPlaceholderText(
160
165
  "optional — added to base (e.g. 0x4A0C68 from a pointer-scan path)"
161
166
  )
162
- self._module_offset_edit.setFont(QFont("Menlo, Consolas, Courier New", 10))
167
+ self._module_offset_edit.setFont(QFont(MONOSPACE_FAMILY, 10))
163
168
  form.addRow("Module offset:", self._module_offset_edit)
164
169
 
165
170
  # Offsets row — a chain of "[+ hex ]" slots with a
@@ -277,14 +282,14 @@ class PointerChainDialog(QDialog):
277
282
 
278
283
  self._output_label = QLabel("Resolved address: —")
279
284
  self._output_label.setObjectName("hint")
280
- self._output_label.setFont(QFont("Menlo, Consolas, Courier New", 11))
285
+ self._output_label.setFont(QFont(MONOSPACE_FAMILY, 11))
281
286
  self._output_label.setTextInteractionFlags(
282
287
  Qt.TextInteractionFlag.TextSelectableByMouse
283
288
  )
284
289
  layout.addWidget(self._output_label)
285
290
 
286
291
  self._value_label = QLabel("Value: —")
287
- self._value_label.setFont(QFont("Menlo, Consolas, Courier New", 11))
292
+ self._value_label.setFont(QFont(MONOSPACE_FAMILY, 11))
288
293
  self._value_label.setTextInteractionFlags(
289
294
  Qt.TextInteractionFlag.TextSelectableByMouse
290
295
  )
@@ -396,18 +401,15 @@ class PointerChainDialog(QDialog):
396
401
  # dereference (it locates the static slot — it is NOT a chain offset).
397
402
  module_offset_text = self._module_offset_edit.text().strip()
398
403
  if module_offset_text:
399
- module_offset = parse_hex_address(module_offset_text)
404
+ module_offset = parse_hex_offset(module_offset_text)
400
405
  if module_offset is None:
401
- try:
402
- module_offset = int(module_offset_text, 16)
403
- except ValueError:
404
- QMessageBox.warning(
405
- self,
406
- "Resolve",
407
- "Module offset must be hex (e.g. 0x4A0C68). Leave it "
408
- "empty if your base address already includes it.",
409
- )
410
- return
406
+ QMessageBox.warning(
407
+ self,
408
+ "Resolve",
409
+ "Module offset must be hex (e.g. 0x4A0C68). Leave it "
410
+ "empty if your base address already includes it.",
411
+ )
412
+ return
411
413
  base += module_offset
412
414
 
413
415
  offsets = self._read_offsets()
@@ -50,7 +50,10 @@ from PySide6.QtWidgets import (
50
50
  )
51
51
 
52
52
  from PyMemoryEditor import AbstractProcess, PointerPath
53
- from PyMemoryEditor.process.pointer_scan import intersect_pointer_paths
53
+ from PyMemoryEditor.process.pointer_scan import (
54
+ first_module_bases,
55
+ intersect_pointer_paths,
56
+ )
54
57
 
55
58
  from ._widgets import (
56
59
  MONOSPACE_FAMILY,
@@ -141,6 +144,9 @@ class PointerScanWorker(QThread):
141
144
  writable_only=req.writable_only,
142
145
  max_results=req.max_results,
143
146
  progress_callback=self._on_map_progress,
147
+ # The walk can run a long time between two paths; this lets
148
+ # Stop end it there too, not only at the next yield.
149
+ should_stop=lambda: self._cancelled,
144
150
  )
145
151
 
146
152
  batch: List[_ResultRow] = []
@@ -246,7 +252,7 @@ class PointerRescanWorker(QThread):
246
252
  def run(self) -> None:
247
253
  try:
248
254
  # One name→base lookup so rebasing each saved path is O(1).
249
- module_bases = {m.name: m.base_address for m in self._process.get_modules()}
255
+ module_bases = first_module_bases(self._process.get_modules())
250
256
 
251
257
  batch: List[_ResultRow] = []
252
258
  total = len(self._paths) or 1
@@ -284,16 +290,8 @@ class PointerRescanWorker(QThread):
284
290
  def _rebase(self, saved: PointerPath, module_bases: dict) -> Optional[PointerPath]:
285
291
  """Move a saved path onto the live module base, or None if not rebasable."""
286
292
  if saved.module is not None and saved.module_offset is not None:
287
- base = module_bases.get(saved.module)
288
- if base is None:
289
- return None # module not loaded in this run — drop the path
290
- return PointerPath(
291
- base_address=base + saved.module_offset,
292
- offsets=saved.offsets,
293
- module=saved.module,
294
- module_offset=saved.module_offset,
295
- ptr_size=saved.ptr_size,
296
- )
293
+ # None when the module isn't loaded in this run — drop the path.
294
+ return saved._rebased_onto(module_bases)
297
295
  # No module: the absolute base is only a best-effort guess post-restart.
298
296
  return saved
299
297
 
@@ -979,23 +977,23 @@ class PointerScanDialog(TearsDownOnClose, QDialog):
979
977
  # For display, rebase each survivor onto the current process and read its
980
978
  # value — best effort; a path that can't resolve now is still a valid
981
979
  # member of the intersection and is kept (shown dimmed / "—").
982
- module_bases = {m.name: m.base_address for m in self._process.get_modules()}
980
+ unavailable = ""
981
+ try:
982
+ module_bases = first_module_bases(self._process.get_modules())
983
+ except Exception as exc: # noqa: BLE001 — display only; the target may be gone
984
+ # Without the module list every survivor is shown unresolved ("—"),
985
+ # which is what the per-path guard below does for a single one —
986
+ # but say why, or a table of dimmed rows reads as "nothing works".
987
+ module_bases = {}
988
+ unavailable = (
989
+ f" Values not shown: the target's modules could not be listed "
990
+ f"({type(exc).__name__}: {exc})."
991
+ )
983
992
  spec, length = self._current_spec()
984
993
 
985
994
  rows: List[_ResultRow] = []
986
995
  for saved in survivors:
987
- base = module_bases.get(saved.module)
988
- live = (
989
- PointerPath(
990
- base_address=base + saved.module_offset,
991
- offsets=saved.offsets,
992
- module=saved.module,
993
- module_offset=saved.module_offset,
994
- ptr_size=saved.ptr_size,
995
- )
996
- if base is not None and saved.module_offset is not None
997
- else saved
998
- )
996
+ live = saved._rebased_onto(module_bases) or saved
999
997
  try:
1000
998
  resolved = live.resolve(self._process)
1001
999
  value = self._process.read_process_memory(resolved, spec.pytype, length)
@@ -1008,6 +1006,7 @@ class PointerScanDialog(TearsDownOnClose, QDialog):
1008
1006
  self._append_rows(rows)
1009
1007
  self._count_label.setText(
1010
1008
  f"{len(survivors):,} path(s) common to {len(filenames)} scans."
1009
+ + unavailable
1011
1010
  )
1012
1011
  self._export_btn.setEnabled(True)
1013
1012