PyMemoryEditor 3.2.0__py3-none-any.whl → 3.3.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 (34) hide show
  1. PyMemoryEditor/__init__.py +1 -1
  2. PyMemoryEditor/app/application.py +2 -2
  3. PyMemoryEditor/app/cheat_poll_worker.py +18 -12
  4. PyMemoryEditor/app/cheat_table.py +66 -28
  5. PyMemoryEditor/app/main_window.py +80 -26
  6. PyMemoryEditor/app/pointer_scan_dialog.py +83 -4
  7. PyMemoryEditor/app/results_view.py +71 -8
  8. PyMemoryEditor/app/scan_worker.py +10 -0
  9. PyMemoryEditor/linux/functions.py +30 -8
  10. PyMemoryEditor/linux/process.py +27 -9
  11. PyMemoryEditor/macos/functions.py +120 -39
  12. PyMemoryEditor/macos/process.py +49 -17
  13. PyMemoryEditor/mcp/config.py +34 -8
  14. PyMemoryEditor/mcp/policy.py +185 -18
  15. PyMemoryEditor/mcp/procinfo.py +258 -0
  16. PyMemoryEditor/mcp/server.py +223 -16
  17. PyMemoryEditor/mcp/session.py +59 -5
  18. PyMemoryEditor/mcp/toolset.py +244 -21
  19. PyMemoryEditor/process/_guard.py +42 -0
  20. PyMemoryEditor/process/abstract.py +54 -11
  21. PyMemoryEditor/process/info.py +3 -1
  22. PyMemoryEditor/process/pointer_scan.py +133 -13
  23. PyMemoryEditor/process/scanning.py +51 -22
  24. PyMemoryEditor/util/__init__.py +1 -0
  25. PyMemoryEditor/util/convert.py +13 -3
  26. PyMemoryEditor/util/pattern.py +11 -1
  27. PyMemoryEditor/util/scan.py +52 -0
  28. PyMemoryEditor/win32/functions.py +90 -26
  29. PyMemoryEditor/win32/process.py +34 -10
  30. {pymemoryeditor-3.2.0.dist-info → pymemoryeditor-3.3.0.dist-info}/METADATA +12 -6
  31. {pymemoryeditor-3.2.0.dist-info → pymemoryeditor-3.3.0.dist-info}/RECORD +34 -32
  32. {pymemoryeditor-3.2.0.dist-info → pymemoryeditor-3.3.0.dist-info}/WHEEL +0 -0
  33. {pymemoryeditor-3.2.0.dist-info → pymemoryeditor-3.3.0.dist-info}/entry_points.txt +0 -0
  34. {pymemoryeditor-3.2.0.dist-info → pymemoryeditor-3.3.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.2.0"
11
+ __version__ = "3.3.0"
12
12
 
13
13
  import logging
14
14
  import sys
@@ -602,8 +602,8 @@ def main(argv=None):
602
602
  # Change Process… released that one and attached the window
603
603
  # to another.
604
604
  window.process.close()
605
- except Exception:
606
- pass
605
+ except Exception: # noqa: BLE001 — exiting anyway; keep the cause
606
+ _LOG.debug("Closing the process handle on exit failed", exc_info=True)
607
607
 
608
608
 
609
609
  def main_cli(argv=None):
@@ -115,20 +115,21 @@ class _CheatPollWorker(QThread):
115
115
  with QMutexLocker(self._mutex):
116
116
  self._stop = True
117
117
 
118
- def _drain_pending_writes(self) -> List[Tuple[int, type, int, str]]:
119
- """Perform and clear every queued manual write.
118
+ def _take_pending_writes(self) -> List[Tuple[int, type, int, Any]]:
119
+ """Detach the queued writes. The caller holds ``self._mutex``."""
120
+ pending = self._pending_writes
121
+ self._pending_writes = []
122
+ return pending
123
+
124
+ def _perform_writes(
125
+ self, pending: List[Tuple[int, type, int, Any]]
126
+ ) -> List[Tuple[int, type, int, str]]:
127
+ """Perform manual writes taken with :meth:`_take_pending_writes`.
120
128
 
121
129
  Returns one ``(address, pytype, length, message)`` tuple per write that
122
- failed, so ``run`` can surface it via ``write_failed``. Reads and clears
123
- the queue under the lock, then runs the syscalls outside it so a slow
124
- write never blocks a UI thread enqueuing the next edit.
130
+ failed, so ``run`` can surface it via ``write_failed``. Runs outside the
131
+ lock, so a slow write never blocks a UI thread enqueuing the next edit.
125
132
  """
126
- with QMutexLocker(self._mutex):
127
- if not self._pending_writes:
128
- return []
129
- pending = self._pending_writes
130
- self._pending_writes = []
131
-
132
133
  failures: List[Tuple[int, type, int, str]] = []
133
134
  for address, pytype, length, value in pending:
134
135
  if self._stopping():
@@ -146,10 +147,15 @@ class _CheatPollWorker(QThread):
146
147
 
147
148
  def run(self) -> None:
148
149
  while True:
150
+ # Snapshot and queued writes are taken together, under one lock:
151
+ # an inline edit on a frozen row publishes the new frozen value and
152
+ # queues its write back to back, and a snapshot copied before that
153
+ # pair would re-freeze the old value over the write just drained.
149
154
  with QMutexLocker(self._mutex):
150
155
  if self._stop:
151
156
  return
152
157
  snapshot = list(self._snapshot)
158
+ pending = self._take_pending_writes()
153
159
 
154
160
  # Never let an exception escape the loop body: a QThread whose
155
161
  # run() raises dies silently, and the table would then stop
@@ -158,7 +164,7 @@ class _CheatPollWorker(QThread):
158
164
  try:
159
165
  # Apply queued manual writes before reading, so a just-typed
160
166
  # value lands and is read back as the new current value this tick.
161
- for failure in self._drain_pending_writes():
167
+ for failure in self._perform_writes(pending):
162
168
  self.write_failed.emit(failure)
163
169
 
164
170
  if snapshot:
@@ -230,27 +230,47 @@ class CheatTable(QWidget):
230
230
  delete_shortcut.activated.connect(self._on_remove_selected)
231
231
 
232
232
  def add_entry(self, entry: CheatEntry) -> None:
233
- # Every entry enters here, whichever way it was created — promoted from
234
- # a scan, from a pointer dialog, added by hand, or loaded from JSON. A
235
- # zero-width buffer reads back empty on every poll tick and can't be
236
- # spotted from the table, so the floor is enforced once, at the door,
237
- # rather than at each of those call sites. (The AOB pattern spec is the
238
- # one whose declared length is 0 — the scanner derives its real width
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)
233
+ self._add_entries([entry])
234
+
235
+ def _add_entries(self, new_entries: List[CheatEntry]) -> None:
236
+ """Merge ``new_entries`` into the table and rebuild it once.
237
+
238
+ Every entry enters here, whichever way it was created — promoted from
239
+ a scan, from a pointer dialog, added by hand, or loaded from JSON. A
240
+ zero-width buffer reads back empty on every poll tick and can't be
241
+ spotted from the table, so the floor is enforced once, at the door,
242
+ rather than at each of those call sites. (The AOB pattern spec is the
243
+ one whose declared length is 0 — the scanner derives its real width
244
+ from the pattern.) The ceiling is the same one every other width
245
+ field enforces (MAX_ENTRY_LENGTH); only a JSON import can carry more,
246
+ and silently resizing its entry would alter what the user exports
247
+ next, so that is refused -- the import loop reports and skips it.
248
+
249
+ Batched so a bulk promotion costs one address lookup per row and one
250
+ rebuild in total: one ``add_entry`` per row did a linear duplicate
251
+ scan plus a full table rebuild each, quadratic in the selection.
252
+
253
+ :raises ValueError: if an entry's length is over ``MAX_ENTRY_LENGTH``;
254
+ checked for every entry before any is added.
255
+ """
256
+ for entry in new_entries:
257
+ length = int(entry.length)
258
+ if length > MAX_ENTRY_LENGTH:
259
+ raise ValueError(
260
+ "entry at 0x%X has length %d, over the %d-byte maximum"
261
+ % (entry.address, length, MAX_ENTRY_LENGTH)
262
+ )
250
263
 
251
- # If the address already exists, just refresh its description/type.
264
+ # First entry per address, as the linear scan used to find.
265
+ by_address: Dict[int, CheatEntry] = {}
252
266
  for existing in self._entries:
253
- if existing.address == entry.address:
267
+ by_address.setdefault(existing.address, existing)
268
+
269
+ for entry in new_entries:
270
+ entry.length = max(1, int(entry.length))
271
+ # If the address already exists, just refresh its description/type.
272
+ existing = by_address.get(entry.address)
273
+ if existing is not None:
254
274
  existing.description = entry.description or existing.description
255
275
  # Re-promoting an address that is already in the table is the
256
276
  # third place a spec_label changes, and the cached value has to
@@ -260,10 +280,10 @@ class CheatTable(QWidget):
260
280
  _forget_value_read_as_another_type(existing)
261
281
  existing.spec_label = entry.spec_label
262
282
  existing.length = entry.length
263
- self._rebuild()
264
- return
283
+ continue
284
+ self._entries.append(entry)
285
+ by_address[entry.address] = entry
265
286
 
266
- self._entries.append(entry)
267
287
  self._rebuild()
268
288
 
269
289
  def add_addresses(
@@ -283,15 +303,17 @@ class CheatTable(QWidget):
283
303
  "length %d is over the %d-byte maximum for a cheat-table entry"
284
304
  % (int(length), MAX_ENTRY_LENGTH)
285
305
  )
286
- for addr in addresses:
287
- self.add_entry(
306
+ self._add_entries(
307
+ [
288
308
  CheatEntry(
289
309
  description=description,
290
310
  address=int(addr),
291
311
  spec_label=spec.label,
292
312
  length=int(length),
293
313
  )
294
- )
314
+ for addr in addresses
315
+ ]
316
+ )
295
317
 
296
318
  def entries(self) -> List[CheatEntry]:
297
319
  return list(self._entries)
@@ -405,14 +427,30 @@ class CheatTable(QWidget):
405
427
  # Nothing to route it to once shutdown() handed the worker over,
406
428
  # and nothing to write to while the target is known to be gone.
407
429
  if self._poller_stopped or self._paused:
430
+ # Put the cell back and say so, as the bulk edit does —
431
+ # returning quietly left the typed text on screen as if it had
432
+ # been written.
433
+ self._suspend_signals = True
434
+ item.setText(self._value_text_for(entry))
435
+ self._suspend_signals = False
436
+ QMessageBox.warning(
437
+ self,
438
+ "Write value",
439
+ f"0x{entry.address:X}: the target process is no longer available.",
440
+ )
408
441
  return
409
- self._poller.request_write(
410
- entry.address, entry.spec.pytype, entry.length, value
411
- )
412
442
 
413
443
  entry.last_value = value
414
444
  if entry.frozen:
415
445
  entry.frozen_value = value
446
+ # Hand the worker the new frozen value *before* queuing the
447
+ # write: its snapshot otherwise still holds the old one until
448
+ # the next publish tick, and the freeze loop would write that
449
+ # stale value straight over the edit.
450
+ self._publish_snapshot_to_worker()
451
+ self._poller.request_write(
452
+ entry.address, entry.spec.pytype, entry.length, value
453
+ )
416
454
 
417
455
  def _publish_snapshot_to_worker(self) -> None:
418
456
  """Hand the worker a fresh immutable snapshot of every entry."""
@@ -383,11 +383,12 @@ class MainWindow(QMainWindow):
383
383
  # The worker builds the region snapshot off the UI thread; cache it here
384
384
  # for the refine/update scans that reuse it.
385
385
  worker.snapshot_ready.connect(self._on_snapshot_ready)
386
- # Connection order matters: _cleanup_worker must clear self._worker
387
- # before _fill_initial_values runs, otherwise the busy guard in
388
- # _on_update_values rejects the auto-refresh.
389
- worker.finished.connect(self._cleanup_worker)
390
- worker.finished.connect(lambda: self._fill_initial_values(request))
386
+ # One slot, not two connections in a load-bearing order: it clears
387
+ # self._worker (via _cleanup_worker) before _fill_initial_values runs,
388
+ # otherwise the busy guard in _on_update_values rejects the
389
+ # auto-refresh — and it runs that refresh only after a scan that went
390
+ # through, not after Cancel or an error.
391
+ worker.finished.connect(lambda: self._on_first_scan_finished(request))
391
392
  self._worker = worker
392
393
  worker.start()
393
394
 
@@ -464,10 +465,25 @@ class MainWindow(QMainWindow):
464
465
  self._worker = worker
465
466
  worker.start()
466
467
 
468
+ def _on_first_scan_finished(self, request: ScanRequest) -> None:
469
+ worker = self._worker
470
+ completed = worker is not None and worker.completed()
471
+ self._cleanup_worker()
472
+ # A cancelled scan still emits finished_ok (with the partial count) and
473
+ # a failed one emits error; either way the user asked for no more
474
+ # work — Cancel started "Updating values…", and a dead target popped
475
+ # a second error dialog from the refresh.
476
+ if completed:
477
+ self._fill_initial_values(request)
478
+
467
479
  def _fill_initial_values(self, request: ScanRequest) -> None:
468
480
  # If the first-scan worker dropped or had zero hits, skip the refresh.
469
481
  if self._results_model.count() == 0:
470
482
  return
483
+ # The heartbeat locked the scanner while the scan ran: the target is
484
+ # gone, so a refresh could only fail.
485
+ if self._target_dead:
486
+ return
471
487
  # Don't recurse into another scan if the user has already triggered one.
472
488
  if self._worker is not None:
473
489
  return
@@ -529,7 +545,18 @@ class MainWindow(QMainWindow):
529
545
  self._status.showMessage(message)
530
546
 
531
547
  def _cleanup_worker(self) -> None:
548
+ # Runs from the worker's `finished`, and only one scan runs at a time,
549
+ # so self._worker is the worker that just finished. It is a child of
550
+ # this window: dropping the reference alone kept every scan's QThread
551
+ # (and the address list / value map it was handed) alive until the
552
+ # window closed. `finished` is emitted as run() returns, so wait()
553
+ # comes back at once; it makes sure the thread is really over before
554
+ # deleteLater() (destroying a running QThread aborts the process).
555
+ worker = self._worker
532
556
  self._worker = None
557
+ if worker is not None:
558
+ worker.wait()
559
+ worker.deleteLater()
533
560
  # A scan that was running when the target exited finishes (with an
534
561
  # error) after the heartbeat locked the scanner; releasing the lock
535
562
  # here would hand the controls back against a dead handle.
@@ -570,7 +597,24 @@ class MainWindow(QMainWindow):
570
597
  snap = self._memory_map.snapshot()
571
598
  if snap:
572
599
  self._region_snapshot = snap
573
- self._memory_map = None
600
+ self._release_dialog("_memory_map")
601
+
602
+ def _release_dialog(self, attr: str) -> None:
603
+ """Forget a closed tool dialog and free it.
604
+
605
+ The dialogs are children of this window and are rebuilt on the next
606
+ open, so dropping the reference alone kept every closed instance (and
607
+ the tables it held) alive for the life of the window. By the time
608
+ ``finished`` gets here the dialog's teardown has already run — it is
609
+ driven from ``done()`` — so its background worker is stopped, or
610
+ detached from it if it would not stop, and the delete can't take a
611
+ running thread down with it. deleteLater(), not delete: this runs
612
+ inside the dialog's own ``done()``.
613
+ """
614
+ dialog = getattr(self, attr, None)
615
+ setattr(self, attr, None)
616
+ if dialog is not None:
617
+ dialog.deleteLater()
574
618
 
575
619
  def _open_threads_dialog(self) -> None:
576
620
  if self._threads_dialog is None:
@@ -583,7 +627,7 @@ class MainWindow(QMainWindow):
583
627
  self._threads_dialog.activateWindow()
584
628
 
585
629
  def _on_threads_dialog_closed(self, _result: int) -> None:
586
- self._threads_dialog = None
630
+ self._release_dialog("_threads_dialog")
587
631
 
588
632
  def _open_modules_dialog(self) -> None:
589
633
  if self._modules_dialog is None:
@@ -602,7 +646,7 @@ class MainWindow(QMainWindow):
602
646
  self._modules_dialog.activateWindow()
603
647
 
604
648
  def _on_modules_dialog_closed(self, _result: int) -> None:
605
- self._modules_dialog = None
649
+ self._release_dialog("_modules_dialog")
606
650
 
607
651
  def _open_pointer_chain_dialog(self) -> None:
608
652
  if self._pointer_chain_dialog is None:
@@ -618,7 +662,7 @@ class MainWindow(QMainWindow):
618
662
  self._pointer_chain_dialog.activateWindow()
619
663
 
620
664
  def _on_pointer_chain_dialog_closed(self, _result: int) -> None:
621
- self._pointer_chain_dialog = None
665
+ self._release_dialog("_pointer_chain_dialog")
622
666
 
623
667
  def _on_module_resolve_chain(self, base_address: int) -> None:
624
668
  """Open the Pointer Chain tool with a module's base address prefilled."""
@@ -675,7 +719,7 @@ class MainWindow(QMainWindow):
675
719
  self._pointer_scan_dialog.activateWindow()
676
720
 
677
721
  def _on_pointer_scan_dialog_closed(self, _result: int) -> None:
678
- self._pointer_scan_dialog = None
722
+ self._release_dialog("_pointer_scan_dialog")
679
723
 
680
724
  def _on_pointer_scan_promote(
681
725
  self, address: int, spec_label: str, length: int
@@ -694,7 +738,7 @@ class MainWindow(QMainWindow):
694
738
  self._log_console_dialog.activateWindow()
695
739
 
696
740
  def _on_log_console_closed(self, _result: int) -> None:
697
- self._log_console_dialog = None
741
+ self._release_dialog("_log_console_dialog")
698
742
 
699
743
  def _open_hex_viewer(self, address: int) -> None:
700
744
  self._open_hex_viewer_with_size(address, 256)
@@ -908,8 +952,8 @@ class MainWindow(QMainWindow):
908
952
  # until GC. shutdown() is idempotent and joins the worker.
909
953
  try:
910
954
  old_cheat.shutdown()
911
- except Exception:
912
- pass
955
+ except Exception: # noqa: BLE001 — teardown must go on; keep the cause
956
+ _LOG.debug("Old cheat table shutdown failed", exc_info=True)
913
957
  # A worker that blew its join is detached, not stopped, so it can still
914
958
  # be inside a syscall on the old handle. Release the handle only once
915
959
  # every detached worker has finished; closing it now could hand its
@@ -963,8 +1007,8 @@ class MainWindow(QMainWindow):
963
1007
  # before the process handle goes away in `application.main`'s finally.
964
1008
  try:
965
1009
  self._cheat.shutdown()
966
- except Exception:
967
- pass
1010
+ except Exception: # noqa: BLE001 — teardown must go on; keep the cause
1011
+ _LOG.debug("Cheat table shutdown failed", exc_info=True)
968
1012
 
969
1013
  self._shutdown_worker()
970
1014
 
@@ -1018,16 +1062,26 @@ class MainWindow(QMainWindow):
1018
1062
  return
1019
1063
  self._worker = None
1020
1064
  worker.cancel()
1021
- try:
1022
- worker.chunk_ready.disconnect()
1023
- worker.progress.disconnect()
1024
- worker.status.disconnect()
1025
- worker.error.disconnect()
1026
- worker.finished_ok.disconnect()
1027
- worker.finished.disconnect()
1028
- except (RuntimeError, TypeError):
1029
- # Already disconnected / no slots — fine, we just want them gone.
1030
- pass
1065
+ # Each signal on its own, so one that has no slots can't leave the
1066
+ # rest connected. snapshot_ready exists on the first-scan worker only;
1067
+ # a late one would cache a snapshot on a window that is going away.
1068
+ for name in (
1069
+ "chunk_ready",
1070
+ "progress",
1071
+ "status",
1072
+ "error",
1073
+ "finished_ok",
1074
+ "snapshot_ready",
1075
+ "finished",
1076
+ ):
1077
+ signal = getattr(worker, name, None)
1078
+ if signal is None:
1079
+ continue
1080
+ try:
1081
+ signal.disconnect()
1082
+ except (RuntimeError, TypeError):
1083
+ # Already disconnected / no slots — fine, we just want them gone.
1084
+ pass
1031
1085
 
1032
1086
  # Pump queued cross-thread calls so any in-flight blocking emit is
1033
1087
  # dispatched (which releases the worker even though we just
@@ -1051,7 +1105,7 @@ def _close_quietly(process) -> None:
1051
1105
  try:
1052
1106
  process.close()
1053
1107
  except Exception: # noqa: BLE001 — the target may already be gone
1054
- pass
1108
+ _LOG.debug("Closing the previous process handle failed", exc_info=True)
1055
1109
 
1056
1110
 
1057
1111
  def _safe_for_json(value) -> object:
@@ -24,6 +24,7 @@ so it survives into the freeze/refresh loop.
24
24
  """
25
25
  import json
26
26
  import logging
27
+ import threading
27
28
  from dataclasses import dataclass
28
29
  from typing import List, Optional, Tuple
29
30
 
@@ -79,6 +80,13 @@ _MONO = MONOSPACE_FAMILY
79
80
  # thousands of paths updates the UI smoothly instead of one row at a time.
80
81
  _UI_BATCH = 200
81
82
 
83
+ # Batches the scan worker may have handed to the UI before it has appended
84
+ # them. Past this the worker waits, so a fast walk with "Unlimited" results
85
+ # can't pile up an unbounded queue of rows behind a busy UI thread. A wait
86
+ # loop rather than a BlockingQueuedConnection: it observes Stop/cancel on its
87
+ # own, so the teardown's plain join never has to pump the event loop.
88
+ _MAX_BATCHES_IN_FLIGHT = 4
89
+
82
90
 
83
91
  class _PointerScanCancelled(Exception):
84
92
  """Raised from the progress callback to abort a scan mid-map-build."""
@@ -120,10 +128,30 @@ class PointerScanWorker(QThread):
120
128
  self._process = process
121
129
  self._request = request
122
130
  self._cancelled = False
131
+ self._in_flight = 0
132
+ self._in_flight_lock = threading.Lock()
123
133
 
124
134
  def cancel(self) -> None:
125
135
  self._cancelled = True
126
136
 
137
+ def batch_consumed(self, *_args) -> None:
138
+ """Acknowledge one ``rows_ready`` batch; connect it after the slot
139
+ that appends the rows. Without it the worker stalls after
140
+ ``_MAX_BATCHES_IN_FLIGHT`` batches, until cancelled."""
141
+ with self._in_flight_lock:
142
+ self._in_flight -= 1
143
+
144
+ def _hand_over(self, batch: List[_ResultRow]) -> None:
145
+ """Emit ``batch`` and wait until the UI has room for the next one."""
146
+ with self._in_flight_lock:
147
+ self._in_flight += 1
148
+ self.rows_ready.emit(batch)
149
+ while not self._cancelled:
150
+ with self._in_flight_lock:
151
+ if self._in_flight < _MAX_BATCHES_IN_FLIGHT:
152
+ return
153
+ QThread.msleep(5)
154
+
127
155
  def _on_map_progress(self, fraction: float) -> None:
128
156
  # Abort the (uninterruptible-from-outside) map build by raising; the
129
157
  # generator unwinds and run() catches the cancellation cleanly.
@@ -168,12 +196,12 @@ class PointerScanWorker(QThread):
168
196
  count += 1
169
197
 
170
198
  if len(batch) >= _UI_BATCH:
171
- self.rows_ready.emit(batch)
199
+ self._hand_over(batch)
172
200
  batch = []
173
201
  self.status.emit(f"Found {count:,} pointer path(s)…")
174
202
 
175
203
  if batch:
176
- self.rows_ready.emit(batch)
204
+ self._hand_over(batch)
177
205
 
178
206
  if self._cancelled:
179
207
  self.status.emit(f"Stopped — {count:,} pointer path(s) found.")
@@ -322,6 +350,10 @@ _COL_OFFSETS = 3
322
350
  _COL_RESOLVED = 4
323
351
  _COL_VALUE = 5
324
352
 
353
+ # Set on a Compare row whose module is not loaded now: the stashed path still
354
+ # carries the previous run's absolute base, so it must not be resolved.
355
+ _ROLE_NO_LIVE_BASE = Qt.UserRole + 1
356
+
325
357
 
326
358
  class PointerScanDialog(TearsDownOnClose, QDialog):
327
359
  """Reverse pointer scan with a Cheat-Engine-style result table."""
@@ -336,6 +368,8 @@ class PointerScanDialog(TearsDownOnClose, QDialog):
336
368
  super().__init__(parent)
337
369
  self._process = process
338
370
  self._worker: Optional[QThread] = None
371
+ # Set by Stop for the running worker, so its finish reads as stopped.
372
+ self._stop_requested = False
339
373
  self._target_address: Optional[int] = None
340
374
 
341
375
  self.setWindowTitle(f"Pointer Scan — PID {process.pid}")
@@ -632,15 +666,21 @@ class PointerScanDialog(TearsDownOnClose, QDialog):
632
666
  worker.progress.connect(lambda p: self._progress.setValue(int(p)))
633
667
  worker.status.connect(self._count_label.setText)
634
668
  worker.rows_ready.connect(self._append_rows)
669
+ # After _append_rows, so a batch is acknowledged once it is drawn.
670
+ batch_consumed = getattr(worker, "batch_consumed", None)
671
+ if batch_consumed is not None:
672
+ worker.rows_ready.connect(batch_consumed)
635
673
  worker.error.connect(self._on_error)
636
674
  worker.finished_ok.connect(self._on_finished_ok)
637
675
  worker.finished.connect(self._on_worker_finished)
638
676
  self._worker = worker
677
+ self._stop_requested = False
639
678
  worker.start()
640
679
 
641
680
  def _on_stop(self) -> None:
642
681
  if self._worker is not None:
643
682
  self._worker.cancel()
683
+ self._stop_requested = True
644
684
  self._count_label.setText("Stopping…")
645
685
 
646
686
  def _set_scanning(self, scanning: bool) -> None:
@@ -738,7 +778,12 @@ class PointerScanDialog(TearsDownOnClose, QDialog):
738
778
  def _on_finished_ok(self, count: int) -> None:
739
779
  self._progress.setValue(100)
740
780
  shown = self._model.rowCount()
741
- if count == 0 and shown == 0:
781
+ if self._stop_requested:
782
+ # Stopped (possibly while the map was still being built, which
783
+ # reports 0): "no paths found, try a higher depth" would misread a
784
+ # scan that never got to look.
785
+ self._count_label.setText(f"Stopped — {shown:,} pointer path(s) found.")
786
+ elif count == 0 and shown == 0:
742
787
  self._count_label.setText(
743
788
  "No pointer paths found. Try a higher depth or max offset, or "
744
789
  "uncheck “Writable memory only”."
@@ -756,6 +801,13 @@ class PointerScanDialog(TearsDownOnClose, QDialog):
756
801
 
757
802
  # ----------------------------------------------------------- actions --- #
758
803
 
804
+ def _selected_has_no_live_base(self) -> bool:
805
+ rows = self._table.selectionModel().selectedRows()
806
+ if not rows:
807
+ return False
808
+ item = self._model.item(rows[0].row(), _COL_PATH)
809
+ return bool(item is not None and item.data(_ROLE_NO_LIVE_BASE))
810
+
759
811
  def _selected_path(self) -> Optional[PointerPath]:
760
812
  rows = self._table.selectionModel().selectedRows()
761
813
  if not rows:
@@ -796,6 +848,17 @@ class PointerScanDialog(TearsDownOnClose, QDialog):
796
848
  path = self._selected_path()
797
849
  if path is None:
798
850
  return None
851
+ if self._selected_has_no_live_base():
852
+ # A Compare survivor whose module is not loaded now: it keeps the
853
+ # saved path, and walking that path's absolute base would read the
854
+ # previous run's addresses -- possibly unrelated memory that maps.
855
+ QMessageBox.warning(
856
+ self,
857
+ "Pointer Scan",
858
+ f"Could not resolve this path now — its module "
859
+ f"{path.module!r} is not loaded in the target.",
860
+ )
861
+ return None
799
862
  try:
800
863
  return path.resolve(self._process)
801
864
  except Exception as exc: # noqa: BLE001
@@ -992,8 +1055,19 @@ class PointerScanDialog(TearsDownOnClose, QDialog):
992
1055
  spec, length = self._current_spec()
993
1056
 
994
1057
  rows: List[_ResultRow] = []
1058
+ no_live_base: List[int] = []
995
1059
  for saved in survivors:
996
- live = saved._rebased_onto(module_bases) or saved
1060
+ # Survivors are all module paths (the intersection drops the
1061
+ # rest). One whose module isn't loaded now has no live base: the
1062
+ # rescan drops it, and falling back to its saved absolute base (as
1063
+ # this used to) walked the previous run's addresses and could show
1064
+ # a value read from unrelated memory as if the path resolved. Its
1065
+ # membership is already decided, so it stays, shown unresolved.
1066
+ live = saved._rebased_onto(module_bases)
1067
+ if live is None:
1068
+ no_live_base.append(len(rows))
1069
+ rows.append((saved, None, "—", False))
1070
+ continue
997
1071
  try:
998
1072
  resolved = live.resolve(self._process)
999
1073
  value = self._process.read_process_memory(resolved, spec.pytype, length)
@@ -1003,7 +1077,12 @@ class PointerScanDialog(TearsDownOnClose, QDialog):
1003
1077
  resolved, text, valid = None, "—", False
1004
1078
  rows.append((live, resolved, text, valid))
1005
1079
 
1080
+ first_row = self._model.rowCount()
1006
1081
  self._append_rows(rows)
1082
+ for index in no_live_base:
1083
+ item = self._model.item(first_row + index, _COL_PATH)
1084
+ if item is not None:
1085
+ item.setData(True, _ROLE_NO_LIVE_BASE)
1007
1086
  self._count_label.setText(
1008
1087
  f"{len(survivors):,} path(s) common to {len(filenames)} scans."
1009
1088
  + unavailable