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.
- PyMemoryEditor/__init__.py +1 -1
- PyMemoryEditor/app/application.py +2 -2
- PyMemoryEditor/app/cheat_poll_worker.py +18 -12
- PyMemoryEditor/app/cheat_table.py +66 -28
- PyMemoryEditor/app/main_window.py +80 -26
- PyMemoryEditor/app/pointer_scan_dialog.py +83 -4
- PyMemoryEditor/app/results_view.py +71 -8
- PyMemoryEditor/app/scan_worker.py +10 -0
- PyMemoryEditor/linux/functions.py +30 -8
- PyMemoryEditor/linux/process.py +27 -9
- PyMemoryEditor/macos/functions.py +120 -39
- PyMemoryEditor/macos/process.py +49 -17
- PyMemoryEditor/mcp/config.py +34 -8
- PyMemoryEditor/mcp/policy.py +185 -18
- PyMemoryEditor/mcp/procinfo.py +258 -0
- PyMemoryEditor/mcp/server.py +223 -16
- PyMemoryEditor/mcp/session.py +59 -5
- PyMemoryEditor/mcp/toolset.py +244 -21
- PyMemoryEditor/process/_guard.py +42 -0
- PyMemoryEditor/process/abstract.py +54 -11
- PyMemoryEditor/process/info.py +3 -1
- PyMemoryEditor/process/pointer_scan.py +133 -13
- PyMemoryEditor/process/scanning.py +51 -22
- PyMemoryEditor/util/__init__.py +1 -0
- PyMemoryEditor/util/convert.py +13 -3
- PyMemoryEditor/util/pattern.py +11 -1
- PyMemoryEditor/util/scan.py +52 -0
- PyMemoryEditor/win32/functions.py +90 -26
- PyMemoryEditor/win32/process.py +34 -10
- {pymemoryeditor-3.2.0.dist-info → pymemoryeditor-3.3.0.dist-info}/METADATA +12 -6
- {pymemoryeditor-3.2.0.dist-info → pymemoryeditor-3.3.0.dist-info}/RECORD +34 -32
- {pymemoryeditor-3.2.0.dist-info → pymemoryeditor-3.3.0.dist-info}/WHEEL +0 -0
- {pymemoryeditor-3.2.0.dist-info → pymemoryeditor-3.3.0.dist-info}/entry_points.txt +0 -0
- {pymemoryeditor-3.2.0.dist-info → pymemoryeditor-3.3.0.dist-info}/licenses/LICENSE +0 -0
PyMemoryEditor/__init__.py
CHANGED
|
@@ -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
|
-
|
|
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
|
|
119
|
-
"""
|
|
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``.
|
|
123
|
-
|
|
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.
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
length
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
264
|
-
|
|
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
|
-
|
|
287
|
-
|
|
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
|
-
#
|
|
387
|
-
# before _fill_initial_values runs,
|
|
388
|
-
# _on_update_values rejects the
|
|
389
|
-
|
|
390
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|