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.
- PyMemoryEditor/__init__.py +1 -1
- PyMemoryEditor/app/_widgets.py +106 -27
- PyMemoryEditor/app/application.py +72 -12
- PyMemoryEditor/app/cheat_table.py +95 -23
- PyMemoryEditor/app/log_console_dialog.py +2 -2
- PyMemoryEditor/app/main_window.py +79 -24
- PyMemoryEditor/app/memory_map_dialog.py +4 -14
- PyMemoryEditor/app/memory_viewer_dialog.py +14 -15
- PyMemoryEditor/app/modules_dialog.py +2 -3
- PyMemoryEditor/app/open_process_dialog.py +2 -14
- PyMemoryEditor/app/pointer_chain_dialog.py +18 -16
- PyMemoryEditor/app/pointer_scan_dialog.py +24 -25
- PyMemoryEditor/app/results_view.py +89 -15
- PyMemoryEditor/app/scan_worker.py +2 -1
- PyMemoryEditor/linux/functions.py +177 -10
- PyMemoryEditor/linux/process.py +100 -12
- PyMemoryEditor/macos/functions.py +416 -166
- PyMemoryEditor/macos/libsystem.py +15 -0
- PyMemoryEditor/macos/process.py +8 -0
- PyMemoryEditor/macos/types.py +52 -1
- PyMemoryEditor/mcp/__init__.py +4 -2
- PyMemoryEditor/mcp/config.py +58 -34
- PyMemoryEditor/mcp/policy.py +108 -16
- PyMemoryEditor/mcp/server.py +48 -22
- PyMemoryEditor/mcp/session.py +227 -58
- PyMemoryEditor/mcp/toolset.py +435 -233
- PyMemoryEditor/process/abstract.py +136 -33
- PyMemoryEditor/process/pointer_scan.py +130 -19
- PyMemoryEditor/process/region.py +1 -4
- PyMemoryEditor/process/remote_pointer.py +11 -3
- PyMemoryEditor/process/scanning.py +241 -122
- PyMemoryEditor/process/thread_info.py +6 -4
- PyMemoryEditor/util/convert.py +51 -50
- PyMemoryEditor/util/pattern.py +38 -6
- PyMemoryEditor/util/scan.py +211 -80
- PyMemoryEditor/util/scan_numpy.py +50 -26
- PyMemoryEditor/win32/functions.py +280 -87
- PyMemoryEditor/win32/process.py +70 -12
- PyMemoryEditor/win32/types.py +23 -16
- {pymemoryeditor-3.0.2.dist-info → pymemoryeditor-3.2.0.dist-info}/METADATA +3 -1
- pymemoryeditor-3.2.0.dist-info/RECORD +73 -0
- pymemoryeditor-3.0.2.dist-info/RECORD +0 -73
- {pymemoryeditor-3.0.2.dist-info → pymemoryeditor-3.2.0.dist-info}/WHEEL +0 -0
- {pymemoryeditor-3.0.2.dist-info → pymemoryeditor-3.2.0.dist-info}/entry_points.txt +0 -0
- {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
|
-
|
|
253
|
+
# Not Ctrl+M / Ctrl+H: Qt maps Ctrl to Cmd on macOS, where those are
|
|
254
|
+
# the system-wide Minimize and Hide shortcuts.
|
|
255
|
+
memory_map_action.setShortcut(QKeySequence("Ctrl+R"))
|
|
246
256
|
memory_map_action.triggered.connect(self._open_memory_map)
|
|
247
257
|
tools_menu.addAction(memory_map_action)
|
|
248
258
|
|
|
249
259
|
hex_viewer_action = QAction("Hex Viewer…", self)
|
|
250
|
-
hex_viewer_action.setShortcut(QKeySequence("Ctrl+H"))
|
|
260
|
+
hex_viewer_action.setShortcut(QKeySequence("Ctrl+Shift+H"))
|
|
251
261
|
hex_viewer_action.triggered.connect(lambda: self._open_hex_viewer(0))
|
|
252
262
|
tools_menu.addAction(hex_viewer_action)
|
|
253
263
|
|
|
@@ -520,7 +530,11 @@ class MainWindow(QMainWindow):
|
|
|
520
530
|
|
|
521
531
|
def _cleanup_worker(self) -> None:
|
|
522
532
|
self._worker = None
|
|
523
|
-
|
|
533
|
+
# A scan that was running when the target exited finishes (with an
|
|
534
|
+
# error) after the heartbeat locked the scanner; releasing the lock
|
|
535
|
+
# here would hand the controls back against a dead handle.
|
|
536
|
+
if not self._target_dead:
|
|
537
|
+
self._set_busy(False)
|
|
524
538
|
|
|
525
539
|
def _set_busy(self, busy: bool) -> None:
|
|
526
540
|
self._scanner.set_busy(busy)
|
|
@@ -529,7 +543,13 @@ class MainWindow(QMainWindow):
|
|
|
529
543
|
if not addresses:
|
|
530
544
|
return
|
|
531
545
|
spec, length = self._scanner.current_spec_and_length()
|
|
532
|
-
|
|
546
|
+
try:
|
|
547
|
+
self._cheat.add_addresses(addresses, spec, length, description="")
|
|
548
|
+
except ValueError as exc:
|
|
549
|
+
# The scanner's width field allows more than the cheat table's
|
|
550
|
+
# MAX_ENTRY_LENGTH; add_entry refuses such a width.
|
|
551
|
+
QMessageBox.warning(self, "Cheat Table", str(exc))
|
|
552
|
+
return
|
|
533
553
|
self._status.showMessage(f"Added {len(addresses)} address(es) to cheat table.")
|
|
534
554
|
|
|
535
555
|
def _open_memory_map(self) -> None:
|
|
@@ -609,15 +629,25 @@ class MainWindow(QMainWindow):
|
|
|
609
629
|
self, address: int, spec_label: str, length: int
|
|
610
630
|
) -> None:
|
|
611
631
|
"""Promote a resolved pointer-chain address into the cheat table."""
|
|
632
|
+
self._promote_resolved_address(address, spec_label, length, "pointer chain")
|
|
633
|
+
|
|
634
|
+
def _promote_resolved_address(
|
|
635
|
+
self, address: int, spec_label: str, length: int, source: str
|
|
636
|
+
) -> None:
|
|
637
|
+
"""Add an address resolved by a pointer tool to the cheat table."""
|
|
612
638
|
entry = CheatEntry(
|
|
613
639
|
description="",
|
|
614
640
|
address=int(address),
|
|
615
641
|
spec_label=spec_label,
|
|
616
642
|
length=int(length),
|
|
617
643
|
)
|
|
618
|
-
|
|
644
|
+
try:
|
|
645
|
+
self._cheat.add_entry(entry)
|
|
646
|
+
except ValueError as exc:
|
|
647
|
+
QMessageBox.warning(self, "Cheat Table", str(exc))
|
|
648
|
+
return
|
|
619
649
|
self._status.showMessage(
|
|
620
|
-
f"Added 0x{address:X} to cheat table (from
|
|
650
|
+
f"Added 0x{address:X} to cheat table (from {source})."
|
|
621
651
|
)
|
|
622
652
|
|
|
623
653
|
def _open_pointer_scan_dialog(self, address: int = 0) -> None:
|
|
@@ -651,16 +681,7 @@ class MainWindow(QMainWindow):
|
|
|
651
681
|
self, address: int, spec_label: str, length: int
|
|
652
682
|
) -> None:
|
|
653
683
|
"""Promote a resolved pointer-scan path's address into the cheat table."""
|
|
654
|
-
|
|
655
|
-
description="",
|
|
656
|
-
address=int(address),
|
|
657
|
-
spec_label=spec_label,
|
|
658
|
-
length=int(length),
|
|
659
|
-
)
|
|
660
|
-
self._cheat.add_entry(entry)
|
|
661
|
-
self._status.showMessage(
|
|
662
|
-
f"Added 0x{address:X} to cheat table (from pointer scan)."
|
|
663
|
-
)
|
|
684
|
+
self._promote_resolved_address(address, spec_label, length, "pointer scan")
|
|
664
685
|
|
|
665
686
|
def _open_log_console(self) -> None:
|
|
666
687
|
if self._log_console_dialog is None:
|
|
@@ -809,7 +830,11 @@ class MainWindow(QMainWindow):
|
|
|
809
830
|
def _check_process_alive(self) -> None:
|
|
810
831
|
if not self._target_is_alive():
|
|
811
832
|
self._heartbeat.stop()
|
|
833
|
+
self._target_dead = True
|
|
812
834
|
self._scanner.set_busy(True)
|
|
835
|
+
# The cheat poller would otherwise keep reading (and, for frozen
|
|
836
|
+
# rows, writing) the dead handle ten times a second.
|
|
837
|
+
self._cheat.set_polling_paused(True)
|
|
813
838
|
self._status.showMessage("Target process exited — operations disabled.")
|
|
814
839
|
QMessageBox.warning(
|
|
815
840
|
self,
|
|
@@ -845,6 +870,7 @@ class MainWindow(QMainWindow):
|
|
|
845
870
|
# If the previous target exited, _check_process_alive locked the scanner
|
|
846
871
|
# via set_busy(True). Switching to a live process must release that lock,
|
|
847
872
|
# otherwise the scan controls stay disabled against the new target.
|
|
873
|
+
self._target_dead = False
|
|
848
874
|
self._scanner.set_busy(False)
|
|
849
875
|
|
|
850
876
|
# Tear down auxiliary dialogs that hold a reference to the old
|
|
@@ -884,13 +910,11 @@ class MainWindow(QMainWindow):
|
|
|
884
910
|
old_cheat.shutdown()
|
|
885
911
|
except Exception:
|
|
886
912
|
pass
|
|
887
|
-
#
|
|
888
|
-
#
|
|
889
|
-
#
|
|
890
|
-
|
|
891
|
-
|
|
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(
|
|
389
|
+
size_item = NumericItem(format_size(size))
|
|
400
390
|
size_item.setData(size, Qt.UserRole)
|
|
401
391
|
size_item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
|
402
392
|
|
|
@@ -416,12 +406,12 @@ class MemoryMapDialog(AutoRefreshTableDialog):
|
|
|
416
406
|
if needle:
|
|
417
407
|
count_text = (
|
|
418
408
|
f"{shown:,} of {total:,} regions shown · "
|
|
419
|
-
f"{
|
|
409
|
+
f"{format_size(total_bytes)} mapped"
|
|
420
410
|
)
|
|
421
411
|
else:
|
|
422
412
|
count_text = (
|
|
423
413
|
f"{total:,} regions · "
|
|
424
|
-
f"{
|
|
414
|
+
f"{format_size(total_bytes)} of virtual address space mapped"
|
|
425
415
|
)
|
|
426
416
|
# A filter keystroke rebuilds this line long after the poll gave up.
|
|
427
417
|
if self._polling_gave_up:
|
|
@@ -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
|
|
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(
|
|
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(
|
|
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
|
-
#
|
|
161
|
-
#
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
277
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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 =
|
|
404
|
+
module_offset = parse_hex_offset(module_offset_text)
|
|
400
405
|
if module_offset is None:
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
288
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|