PyMemoryEditor 3.2.0__py3-none-any.whl → 3.3.1__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/_auto_refresh_dialog.py +6 -7
- PyMemoryEditor/app/_widgets.py +7 -7
- PyMemoryEditor/app/application.py +5 -6
- PyMemoryEditor/app/cheat_poll_worker.py +23 -17
- PyMemoryEditor/app/cheat_table.py +78 -38
- PyMemoryEditor/app/main_window.py +84 -30
- PyMemoryEditor/app/memory_viewer_dialog.py +5 -5
- PyMemoryEditor/app/open_process_dialog.py +1 -1
- PyMemoryEditor/app/pointer_chain_dialog.py +2 -1
- PyMemoryEditor/app/pointer_scan_dialog.py +85 -6
- PyMemoryEditor/app/results_view.py +76 -13
- PyMemoryEditor/app/scan_worker.py +10 -0
- PyMemoryEditor/app/scanner_panel.py +1 -1
- PyMemoryEditor/app/value_types.py +3 -3
- PyMemoryEditor/linux/functions.py +58 -25
- PyMemoryEditor/linux/libc.py +2 -3
- PyMemoryEditor/linux/process.py +43 -88
- PyMemoryEditor/macos/functions.py +150 -56
- PyMemoryEditor/macos/process.py +60 -102
- PyMemoryEditor/mcp/config.py +46 -21
- PyMemoryEditor/mcp/policy.py +191 -24
- PyMemoryEditor/mcp/procinfo.py +258 -0
- PyMemoryEditor/mcp/server.py +232 -25
- PyMemoryEditor/mcp/session.py +74 -22
- PyMemoryEditor/mcp/toolset.py +318 -100
- PyMemoryEditor/process/_guard.py +43 -0
- PyMemoryEditor/process/abstract.py +273 -31
- PyMemoryEditor/process/info.py +3 -1
- PyMemoryEditor/process/pointer_scan.py +134 -13
- PyMemoryEditor/process/region.py +10 -2
- PyMemoryEditor/process/scanning.py +60 -35
- PyMemoryEditor/util/__init__.py +1 -0
- PyMemoryEditor/util/convert.py +13 -3
- PyMemoryEditor/util/pattern.py +11 -1
- PyMemoryEditor/util/scan.py +53 -2
- PyMemoryEditor/win32/enums/memory_protections.py +1 -1
- PyMemoryEditor/win32/functions.py +115 -40
- PyMemoryEditor/win32/process.py +48 -100
- {pymemoryeditor-3.2.0.dist-info → pymemoryeditor-3.3.1.dist-info}/METADATA +12 -6
- pymemoryeditor-3.3.1.dist-info/RECORD +75 -0
- pymemoryeditor-3.2.0.dist-info/RECORD +0 -73
- {pymemoryeditor-3.2.0.dist-info → pymemoryeditor-3.3.1.dist-info}/WHEEL +0 -0
- {pymemoryeditor-3.2.0.dist-info → pymemoryeditor-3.3.1.dist-info}/entry_points.txt +0 -0
- {pymemoryeditor-3.2.0.dist-info → pymemoryeditor-3.3.1.dist-info}/licenses/LICENSE +0 -0
PyMemoryEditor/__init__.py
CHANGED
|
@@ -3,13 +3,12 @@
|
|
|
3
3
|
Shared base for the read-only, auto-refreshing table dialogs (Memory Map,
|
|
4
4
|
Modules, Threads).
|
|
5
5
|
|
|
6
|
-
All three
|
|
6
|
+
All three do the same dance — run a one-shot read off the UI thread, repopulate
|
|
7
7
|
a sortable table when it returns, poll on a timer, and tear the worker down
|
|
8
|
-
safely on close
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
only what differs: how to fetch the data, and how to render it.
|
|
8
|
+
safely on close. This owns that lifecycle once, so the fiddly bits (the
|
|
9
|
+
in-flight guard that self-throttles the poll, the detach-on-shutdown safety)
|
|
10
|
+
live in one place; subclasses provide only what differs: how to fetch the
|
|
11
|
+
data, and how to render it.
|
|
13
12
|
|
|
14
13
|
It also owns *when* a failure is worth interrupting the user — see
|
|
15
14
|
:meth:`AutoRefreshTableDialog._handle_failed`.
|
|
@@ -182,7 +181,7 @@ class AutoRefreshTableDialog(TearsDownOnClose, QDialog):
|
|
|
182
181
|
Process) *every* tick fails, and ``_on_data_failed`` pops a **modal**
|
|
183
182
|
dialog. A modal runs a nested event loop, so the timer fires again while
|
|
184
183
|
it is up and the failures stack — hundreds of focus-stealing dialogs the
|
|
185
|
-
user could only escape by killing the app from another TTY
|
|
184
|
+
user could only escape by killing the app from another TTY.
|
|
186
185
|
|
|
187
186
|
Reporting is therefore tied to the two moments where a failure changes
|
|
188
187
|
what the user sees: the **first** fetch (empty window) and the one that
|
PyMemoryEditor/app/_widgets.py
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
"""Small Qt widgets shared between dialogs.
|
|
4
4
|
|
|
5
|
-
Centralises tiny helpers (numeric sort items, hex address parsing)
|
|
6
|
-
|
|
5
|
+
Centralises tiny helpers (numeric sort items, hex address parsing) shared by
|
|
6
|
+
several dialog modules.
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
9
|
import threading
|
|
@@ -46,9 +46,9 @@ def shutdown_worker_thread(worker: Optional[QThread], wait_ms: int = 2000) -> No
|
|
|
46
46
|
cancel()
|
|
47
47
|
try:
|
|
48
48
|
# The four-argument static form on purpose: bare `worker.disconnect()`
|
|
49
|
-
# raises TypeError in PySide6 (
|
|
50
|
-
#
|
|
51
|
-
#
|
|
49
|
+
# raises TypeError in PySide6, and `worker.disconnect(worker)` only
|
|
50
|
+
# drops connections whose *receiver* is the worker — ours belong to the
|
|
51
|
+
# dialog.
|
|
52
52
|
QObject.disconnect(worker, None, None, None)
|
|
53
53
|
except (RuntimeError, TypeError):
|
|
54
54
|
pass
|
|
@@ -141,8 +141,8 @@ class TearsDownOnClose:
|
|
|
141
141
|
``QDialog`` delivers a ``QCloseEvent`` only for a real close. The app's own
|
|
142
142
|
"Close" buttons call ``accept()`` and Esc reaches ``reject()``; both land in
|
|
143
143
|
``done()``, which hides the dialog and emits ``finished`` *without* any
|
|
144
|
-
close event — so a teardown living in ``closeEvent`` never
|
|
145
|
-
dialog
|
|
144
|
+
close event — so a teardown living in ``closeEvent`` would never run, and
|
|
145
|
+
the dialog would keep polling, hidden and unreachable.
|
|
146
146
|
|
|
147
147
|
Subclasses implement :meth:`_teardown`; the mixin runs it exactly once,
|
|
148
148
|
whichever way the dialog is dismissed, and must precede ``QDialog`` in the
|
|
@@ -542,8 +542,7 @@ def main(argv=None):
|
|
|
542
542
|
|
|
543
543
|
``argv`` defaults to ``sys.argv`` so packaging tools (which call
|
|
544
544
|
``main()`` with no arguments) keep working. Tests and embedders can pass
|
|
545
|
-
an explicit list
|
|
546
|
-
ignored, which made the parameter meaningless.
|
|
545
|
+
an explicit list.
|
|
547
546
|
|
|
548
547
|
Terminal users want Ctrl+C to kill the app, which takes a process-wide
|
|
549
548
|
signal change — see :func:`main_cli`, the console-script entry point.
|
|
@@ -602,8 +601,8 @@ def main(argv=None):
|
|
|
602
601
|
# Change Process… released that one and attached the window
|
|
603
602
|
# to another.
|
|
604
603
|
window.process.close()
|
|
605
|
-
except Exception:
|
|
606
|
-
|
|
604
|
+
except Exception: # noqa: BLE001 — exiting anyway; keep the cause
|
|
605
|
+
_LOG.debug("Closing the process handle on exit failed", exc_info=True)
|
|
607
606
|
|
|
608
607
|
|
|
609
608
|
def main_cli(argv=None):
|
|
@@ -611,9 +610,9 @@ def main_cli(argv=None):
|
|
|
611
610
|
Entry point for the ``pymemoryeditor`` console script and ``python -m``.
|
|
612
611
|
|
|
613
612
|
Qt's event loop blocks inside C++, so Python's default SIGINT handler only
|
|
614
|
-
runs once the interpreter next regains control. In practice it
|
|
613
|
+
runs once the interpreter next regains control. In practice it raises
|
|
615
614
|
KeyboardInterrupt inside our own _PointerCursorFilter.eventFilter override,
|
|
616
|
-
where PySide6 swallows it and merely prints a traceback
|
|
615
|
+
where PySide6 swallows it and merely prints a traceback. Hand SIGINT
|
|
617
616
|
back to the OS for the duration of the run so Ctrl+C from a terminal
|
|
618
617
|
terminates the app immediately.
|
|
619
618
|
|
|
@@ -56,8 +56,8 @@ class _CheatPollWorker(QThread):
|
|
|
56
56
|
with the current ``{key: "ErrorType: message"}`` map. The signal fires only
|
|
57
57
|
when that set *changes* (a freeze starts or stops failing), so the UI gets a
|
|
58
58
|
persistent cue without 10 Hz spam, and the first failure of each entry is
|
|
59
|
-
logged once.
|
|
60
|
-
|
|
59
|
+
logged once. Swallowing the exception instead would make a freeze that
|
|
60
|
+
never landed look identical to one that did.
|
|
61
61
|
"""
|
|
62
62
|
|
|
63
63
|
values_ready = Signal(object) # list[tuple[int, type, int, Any]]
|
|
@@ -96,9 +96,9 @@ class _CheatPollWorker(QThread):
|
|
|
96
96
|
) -> None:
|
|
97
97
|
"""Queue a one-shot manual write to be performed on the worker thread.
|
|
98
98
|
|
|
99
|
-
The cheat table's inline value edits
|
|
100
|
-
``write_process_memory`` directly on the UI thread
|
|
101
|
-
the target
|
|
99
|
+
The cheat table's inline value edits come through here: calling
|
|
100
|
+
``write_process_memory`` directly on the UI thread would freeze the UI
|
|
101
|
+
when the target is slow or the page faults. Routing them here keeps the
|
|
102
102
|
syscall off the UI thread; a write that fails comes back via the
|
|
103
103
|
``write_failed`` signal. Latency is at most one tick (~100 ms), which is
|
|
104
104
|
imperceptible for a manual edit.
|
|
@@ -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:
|
|
@@ -12,8 +12,8 @@ stays fresh.
|
|
|
12
12
|
This module hosts only the Qt widget; the dataclass and the worker thread
|
|
13
13
|
live in ``cheat_entry.py`` and ``cheat_poll_worker.py`` respectively. The
|
|
14
14
|
``CheatEntry`` and ``_CheatPollWorker`` names are re-exported from here for
|
|
15
|
-
backward compatibility with code (and tests) that
|
|
16
|
-
module
|
|
15
|
+
backward compatibility with code (and tests) that import them from this
|
|
16
|
+
module.
|
|
17
17
|
"""
|
|
18
18
|
import copy
|
|
19
19
|
import json
|
|
@@ -95,8 +95,8 @@ class CheatTable(QWidget):
|
|
|
95
95
|
self._poller.start()
|
|
96
96
|
|
|
97
97
|
# A short cadence to push fresh entry snapshots into the worker. This
|
|
98
|
-
# is
|
|
99
|
-
#
|
|
98
|
+
# is cheap — it does no syscalls, it only copies a small list of
|
|
99
|
+
# tuples.
|
|
100
100
|
self._publish_timer = QTimer(self)
|
|
101
101
|
self._publish_timer.setInterval(TICK_INTERVAL_MS)
|
|
102
102
|
self._publish_timer.timeout.connect(self._publish_snapshot_to_worker)
|
|
@@ -221,8 +221,8 @@ class CheatTable(QWidget):
|
|
|
221
221
|
self._table.customContextMenuRequested.connect(self._show_context_menu)
|
|
222
222
|
layout.addWidget(self._table, 1)
|
|
223
223
|
|
|
224
|
-
# Delete removes the selected rows — the standard gesture,
|
|
225
|
-
#
|
|
224
|
+
# Delete removes the selected rows — the standard gesture, alongside
|
|
225
|
+
# the context menu. Scoped to the table widget
|
|
226
226
|
# (WidgetShortcut) so Delete still edits text while a cell is in
|
|
227
227
|
# inline-edit mode (the editor child, not the table, has focus then).
|
|
228
228
|
delete_shortcut = QShortcut(QKeySequence(QKeySequence.Delete), self._table)
|
|
@@ -230,27 +230,48 @@ 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 would do a linear
|
|
251
|
+
duplicate scan plus a full table rebuild each, quadratic in the
|
|
252
|
+
selection.
|
|
253
|
+
|
|
254
|
+
:raises ValueError: if an entry's length is over ``MAX_ENTRY_LENGTH``;
|
|
255
|
+
checked for every entry before any is added.
|
|
256
|
+
"""
|
|
257
|
+
for entry in new_entries:
|
|
258
|
+
length = int(entry.length)
|
|
259
|
+
if length > MAX_ENTRY_LENGTH:
|
|
260
|
+
raise ValueError(
|
|
261
|
+
"entry at 0x%X has length %d, over the %d-byte maximum"
|
|
262
|
+
% (entry.address, length, MAX_ENTRY_LENGTH)
|
|
263
|
+
)
|
|
250
264
|
|
|
251
|
-
#
|
|
265
|
+
# First entry per address wins, should the table hold duplicates.
|
|
266
|
+
by_address: Dict[int, CheatEntry] = {}
|
|
252
267
|
for existing in self._entries:
|
|
253
|
-
|
|
268
|
+
by_address.setdefault(existing.address, existing)
|
|
269
|
+
|
|
270
|
+
for entry in new_entries:
|
|
271
|
+
entry.length = max(1, int(entry.length))
|
|
272
|
+
# If the address already exists, just refresh its description/type.
|
|
273
|
+
existing = by_address.get(entry.address)
|
|
274
|
+
if existing is not None:
|
|
254
275
|
existing.description = entry.description or existing.description
|
|
255
276
|
# Re-promoting an address that is already in the table is the
|
|
256
277
|
# third place a spec_label changes, and the cached value has to
|
|
@@ -260,10 +281,10 @@ class CheatTable(QWidget):
|
|
|
260
281
|
_forget_value_read_as_another_type(existing)
|
|
261
282
|
existing.spec_label = entry.spec_label
|
|
262
283
|
existing.length = entry.length
|
|
263
|
-
|
|
264
|
-
|
|
284
|
+
continue
|
|
285
|
+
self._entries.append(entry)
|
|
286
|
+
by_address[entry.address] = entry
|
|
265
287
|
|
|
266
|
-
self._entries.append(entry)
|
|
267
288
|
self._rebuild()
|
|
268
289
|
|
|
269
290
|
def add_addresses(
|
|
@@ -283,15 +304,17 @@ class CheatTable(QWidget):
|
|
|
283
304
|
"length %d is over the %d-byte maximum for a cheat-table entry"
|
|
284
305
|
% (int(length), MAX_ENTRY_LENGTH)
|
|
285
306
|
)
|
|
286
|
-
|
|
287
|
-
|
|
307
|
+
self._add_entries(
|
|
308
|
+
[
|
|
288
309
|
CheatEntry(
|
|
289
310
|
description=description,
|
|
290
311
|
address=int(addr),
|
|
291
312
|
spec_label=spec.label,
|
|
292
313
|
length=int(length),
|
|
293
314
|
)
|
|
294
|
-
|
|
315
|
+
for addr in addresses
|
|
316
|
+
]
|
|
317
|
+
)
|
|
295
318
|
|
|
296
319
|
def entries(self) -> List[CheatEntry]:
|
|
297
320
|
return list(self._entries)
|
|
@@ -325,8 +348,8 @@ class CheatTable(QWidget):
|
|
|
325
348
|
type_label = entry.spec_label
|
|
326
349
|
# Show the width whenever it belongs to the entry rather than to the
|
|
327
350
|
# spec. An IDA pattern declares none (length 0) yet carries a real one
|
|
328
|
-
# here — writes are refused against it — so hiding it
|
|
329
|
-
# told to "widen the entry" with no way to see what it holds.
|
|
351
|
+
# here — writes are refused against it — so hiding it would leave the
|
|
352
|
+
# user told to "widen the entry" with no way to see what it holds.
|
|
330
353
|
if entry.spec.accepts_length_override or not entry.spec.length:
|
|
331
354
|
type_label += f" · {entry.length}B"
|
|
332
355
|
type_item = QTableWidgetItem(type_label)
|
|
@@ -405,14 +428,30 @@ class CheatTable(QWidget):
|
|
|
405
428
|
# Nothing to route it to once shutdown() handed the worker over,
|
|
406
429
|
# and nothing to write to while the target is known to be gone.
|
|
407
430
|
if self._poller_stopped or self._paused:
|
|
431
|
+
# Put the cell back and say so, as the bulk edit does —
|
|
432
|
+
# returning quietly would leave the typed text on screen as if
|
|
433
|
+
# it had been written.
|
|
434
|
+
self._suspend_signals = True
|
|
435
|
+
item.setText(self._value_text_for(entry))
|
|
436
|
+
self._suspend_signals = False
|
|
437
|
+
QMessageBox.warning(
|
|
438
|
+
self,
|
|
439
|
+
"Write value",
|
|
440
|
+
f"0x{entry.address:X}: the target process is no longer available.",
|
|
441
|
+
)
|
|
408
442
|
return
|
|
409
|
-
self._poller.request_write(
|
|
410
|
-
entry.address, entry.spec.pytype, entry.length, value
|
|
411
|
-
)
|
|
412
443
|
|
|
413
444
|
entry.last_value = value
|
|
414
445
|
if entry.frozen:
|
|
415
446
|
entry.frozen_value = value
|
|
447
|
+
# Hand the worker the new frozen value *before* queuing the
|
|
448
|
+
# write: its snapshot otherwise still holds the old one until
|
|
449
|
+
# the next publish tick, and the freeze loop would write that
|
|
450
|
+
# stale value straight over the edit.
|
|
451
|
+
self._publish_snapshot_to_worker()
|
|
452
|
+
self._poller.request_write(
|
|
453
|
+
entry.address, entry.spec.pytype, entry.length, value
|
|
454
|
+
)
|
|
416
455
|
|
|
417
456
|
def _publish_snapshot_to_worker(self) -> None:
|
|
418
457
|
"""Hand the worker a fresh immutable snapshot of every entry."""
|
|
@@ -487,7 +526,8 @@ class CheatTable(QWidget):
|
|
|
487
526
|
against it here is cheap and keeps the cue in sync: a red value cell plus
|
|
488
527
|
an explanatory tooltip while a freeze keeps failing, restored to normal
|
|
489
528
|
the moment the write lands again. Without this, a frozen value that the
|
|
490
|
-
backend silently refuses to write
|
|
529
|
+
backend silently refuses to write would look identical to one that
|
|
530
|
+
worked.
|
|
491
531
|
"""
|
|
492
532
|
rows_by_key: Dict[Tuple[int, type, int], int] = {}
|
|
493
533
|
for row, entry in enumerate(self._entries):
|
|
@@ -873,7 +913,7 @@ class CheatTable(QWidget):
|
|
|
873
913
|
|
|
874
914
|
|
|
875
915
|
# Ceiling for an entry's buffer width. Entries are promoted at the width of the
|
|
876
|
-
# value scanned for, which the scanner doesn't cap, so 1024
|
|
916
|
+
# value scanned for, which the scanner doesn't cap, so 1024 is too tight — but
|
|
877
917
|
# the poll worker allocates this many bytes per entry on every 100 ms tick, so
|
|
878
918
|
# an unbounded field turns one typo into a multi-gigabyte allocation the tick's
|
|
879
919
|
# blanket except swallows and retries forever. A megabyte is far past any real
|
|
@@ -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 — otherwise Cancel would start "Updating values…", and a dead
|
|
475
|
+
# target would pop 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 would keep every scan's
|
|
551
|
+
# QThread (and the address list / value map it was handed) alive until
|
|
552
|
+
# the 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 would keep every closed instance
|
|
607
|
+
(and 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)
|
|
@@ -875,8 +919,8 @@ class MainWindow(QMainWindow):
|
|
|
875
919
|
|
|
876
920
|
# Tear down auxiliary dialogs that hold a reference to the old
|
|
877
921
|
# process — reopening them rebuilds against the new target. The Memory
|
|
878
|
-
# Map belongs here too: it polls on a timer, so leaving it open
|
|
879
|
-
# reading the handle closed just below
|
|
922
|
+
# Map belongs here too: it polls on a timer, so leaving it open would
|
|
923
|
+
# leave it reading the handle closed just below.
|
|
880
924
|
for dialog_attr in (
|
|
881
925
|
"_memory_map",
|
|
882
926
|
"_threads_dialog",
|
|
@@ -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
|
|
|
@@ -975,8 +1019,8 @@ class MainWindow(QMainWindow):
|
|
|
975
1019
|
"""Ask before quitting with unsaved cheat entries. True = go ahead.
|
|
976
1020
|
|
|
977
1021
|
The table is not persisted between runs (Export… is the way to keep
|
|
978
|
-
it), so a stray Cmd+Q / window close
|
|
979
|
-
address silently.
|
|
1022
|
+
it), so a stray Cmd+Q / window close would otherwise throw away every
|
|
1023
|
+
saved address silently.
|
|
980
1024
|
"""
|
|
981
1025
|
count = self._cheat.entry_count()
|
|
982
1026
|
if count == 0 or not self.confirm_close_with_entries:
|
|
@@ -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:
|
|
@@ -151,9 +151,9 @@ class MemoryViewerDialog(TearsDownOnClose, QDialog):
|
|
|
151
151
|
self._timer = QTimer(self)
|
|
152
152
|
self._timer.timeout.connect(self.refresh)
|
|
153
153
|
|
|
154
|
-
# Failure bookkeeping, same shape as the auto-refresh dialogs:
|
|
155
|
-
# target
|
|
156
|
-
# floor). Keyed by (address, size, message) so moving the viewer to
|
|
154
|
+
# Failure bookkeeping, same shape as the auto-refresh dialogs: without
|
|
155
|
+
# it a dead target would log a warning every tick forever (~20/s at the
|
|
156
|
+
# 50 ms floor). Keyed by (address, size, message) so moving the viewer to
|
|
157
157
|
# another failing range still gets logged.
|
|
158
158
|
self._last_failure: Optional[tuple] = None
|
|
159
159
|
self._failing_since: Optional[QElapsedTimer] = None
|
|
@@ -171,7 +171,7 @@ class MemoryViewerDialog(TearsDownOnClose, QDialog):
|
|
|
171
171
|
# Skip if a read is still running. The auto-refresh timer fires this
|
|
172
172
|
# repeatedly, and a large length (up to 65536) on a slow target
|
|
173
173
|
# (notably macOS Mach-VM) takes long enough that running it on the UI
|
|
174
|
-
# thread
|
|
174
|
+
# thread would freeze input — so the read runs on a worker, and this guard
|
|
175
175
|
# self-throttles the cadence to the real read time instead of stacking
|
|
176
176
|
# workers.
|
|
177
177
|
if self._worker is not None and self._worker.isRunning():
|
|
@@ -188,7 +188,7 @@ class MemoryViewerDialog(TearsDownOnClose, QDialog):
|
|
|
188
188
|
# travel back in the result so the rendered dump stays labelled with
|
|
189
189
|
# the range actually read even if the user edits the fields mid-read.
|
|
190
190
|
# Errors are returned (not raised) so the one result handler renders
|
|
191
|
-
#
|
|
191
|
+
# every outcome through a single message/log path.
|
|
192
192
|
try:
|
|
193
193
|
data = process.read_process_memory(addr, bytes, size)
|
|
194
194
|
# A backend returning a non-buffer object would make bytes(data)
|
|
@@ -288,7 +288,7 @@ class OpenProcessDialog(TearsDownOnClose, QDialog):
|
|
|
288
288
|
selected_pid = self._selected_pid()
|
|
289
289
|
|
|
290
290
|
# Restoring the selection below re-triggers Qt's scrollTo(current), which
|
|
291
|
-
#
|
|
291
|
+
# would jump the list back to the selected row on every tick. The
|
|
292
292
|
# rebuild is harmless on its own: Qt defers the scrollbar range update.
|
|
293
293
|
scrollbar = self._table.verticalScrollBar()
|
|
294
294
|
scroll_offset = scrollbar.value()
|