PyMemoryEditor 2.1.0__py3-none-any.whl → 2.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.
@@ -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__ = "2.1.0"
11
+ __version__ = "2.2.0"
12
12
 
13
13
  import logging
14
14
  import sys
@@ -48,7 +48,12 @@ from PyMemoryEditor import AbstractProcess
48
48
  from ._widgets import parse_hex_address, shutdown_worker_thread
49
49
  from .cheat_entry import CheatEntry
50
50
  from .cheat_poll_worker import TICK_INTERVAL_MS, _CheatPollWorker
51
- from .value_types import VALUE_TYPES, ValueTypeSpec, find_spec, parse_value
51
+ from .value_types import (
52
+ VALUE_TYPES,
53
+ ValueTypeSpec,
54
+ find_spec,
55
+ parse_value_for_write,
56
+ )
52
57
 
53
58
 
54
59
  # Re-exported for backward compatibility with callers that imported the
@@ -197,10 +202,25 @@ class CheatTable(QWidget):
197
202
  delete_shortcut.activated.connect(self._on_remove_selected)
198
203
 
199
204
  def add_entry(self, entry: CheatEntry) -> None:
205
+ # Every entry enters here, whichever way it was created — promoted from
206
+ # a scan, from a pointer dialog, added by hand, or loaded from JSON. A
207
+ # zero-width buffer reads back empty on every poll tick and can't be
208
+ # spotted from the table, so the floor is enforced once, at the door,
209
+ # rather than at each of those call sites. (The AOB pattern spec is the
210
+ # one whose declared length is 0 — the scanner derives its real width
211
+ # from the pattern.)
212
+ entry.length = max(1, int(entry.length))
213
+
200
214
  # If the address already exists, just refresh its description/type.
201
215
  for existing in self._entries:
202
216
  if existing.address == entry.address:
203
217
  existing.description = entry.description or existing.description
218
+ # Re-promoting an address that is already in the table is the
219
+ # third place a spec_label changes, and the cached value has to
220
+ # go with it here too — _rebuild formats it through the new
221
+ # spec on the way out of this method.
222
+ if entry.spec_label != existing.spec_label:
223
+ _forget_value_read_as_another_type(existing)
204
224
  existing.spec_label = entry.spec_label
205
225
  existing.length = entry.length
206
226
  self._rebuild()
@@ -257,7 +277,11 @@ class CheatTable(QWidget):
257
277
  self._table.setItem(row, self.COL_ADDRESS, addr)
258
278
 
259
279
  type_label = entry.spec_label
260
- if entry.spec.accepts_length_override:
280
+ # Show the width whenever it belongs to the entry rather than to the
281
+ # spec. An IDA pattern declares none (length 0) yet carries a real one
282
+ # here — writes are refused against it — so hiding it left the user
283
+ # told to "widen the entry" with no way to see what it holds.
284
+ if entry.spec.accepts_length_override or not entry.spec.length:
261
285
  type_label += f" · {entry.length}B"
262
286
  type_item = QTableWidgetItem(type_label)
263
287
  type_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable)
@@ -312,7 +336,9 @@ class CheatTable(QWidget):
312
336
  # Treat empty as "unfreeze and clear" — no-op.
313
337
  return
314
338
  try:
315
- value, _length = parse_value(entry.spec, text, entry.length)
339
+ value, _length = parse_value_for_write(
340
+ entry.spec, text, entry.length, entry.last_value
341
+ )
316
342
  except ValueError as exc:
317
343
  QMessageBox.warning(self, "Invalid Value", str(exc))
318
344
  self._suspend_signals = True
@@ -390,6 +416,13 @@ class CheatTable(QWidget):
390
416
  continue
391
417
  entry = self._entries[row]
392
418
  entry.last_value = value
419
+ # A frozen row whose baseline was dropped — its type changed,
420
+ # so what the old spec had read no longer means anything —
421
+ # re-adopts the first value read under the new one. The poll
422
+ # worker skips a frozen entry with no frozen_value, so without
423
+ # this the Active box stays ticked while nothing is written.
424
+ if entry.frozen and entry.frozen_value is None:
425
+ entry.frozen_value = value
393
426
  self._update_value_cell(row, entry)
394
427
  finally:
395
428
  self._suspend_signals = False
@@ -528,16 +561,40 @@ class CheatTable(QWidget):
528
561
  if plan.description is not None:
529
562
  entry.description = plan.description
530
563
 
531
- if plan.spec is not None:
564
+ # What the row was showing before this plan touched it. A type
565
+ # change forgets it, but a write in the same pass still needs
566
+ # it: an IDA '?' keeps the byte that is already there, and
567
+ # Byte Array → AOB doesn't change what those bytes mean.
568
+ # parse_value_for_write ignores it when the type genuinely
569
+ # changed shape, so a stale int can't be misread as bytes.
570
+ current = entry.last_value
571
+ retyped = False
572
+ if plan.spec is not None and plan.spec.label != entry.spec_label:
573
+ retyped = True
532
574
  entry.spec_label = plan.spec.label
575
+ _forget_value_read_as_another_type(entry)
576
+ if plan.spec is not None:
533
577
  if not plan.spec.accepts_length_override:
534
- entry.length = plan.spec.length
578
+ # `or entry.length`: the AOB pattern spec declares a
579
+ # length of 0 — the scanner derives a match's width from
580
+ # the pattern, and an entry has none to derive from — so
581
+ # keep the width it already has.
582
+ entry.length = plan.spec.length or entry.length
535
583
 
536
584
  if plan.value_text is not None:
537
585
  spec = entry.spec
586
+ # A retype to a variable-width spec re-sizes the entry from
587
+ # the value, rather than inheriting the width of the type it
588
+ # replaced. Otherwise three Int32 rows retyped to String
589
+ # with "hello" all fail on "the entry holds 4 — widen it
590
+ # first", and the bulk dialog has no width field, nor does
591
+ # a multi-row selection offer one: a dead end.
592
+ cap: Optional[int] = entry.length
593
+ if retyped and spec.accepts_length_override:
594
+ cap = None
538
595
  try:
539
- value, effective_length = parse_value(
540
- spec, plan.value_text, entry.length
596
+ value, effective_length = parse_value_for_write(
597
+ spec, plan.value_text, cap, current
541
598
  )
542
599
  except ValueError as exc:
543
600
  failures.append((entry.address, str(exc)))
@@ -665,10 +722,15 @@ class CheatTable(QWidget):
665
722
  )
666
723
  if not ok:
667
724
  return
668
- self._entries[row].spec_label = chosen
725
+ entry = self._entries[row]
726
+ if chosen != entry.spec_label:
727
+ entry.spec_label = chosen
728
+ _forget_value_read_as_another_type(entry)
669
729
  spec = find_spec(chosen) or VALUE_TYPES[0]
670
730
  if not spec.accepts_length_override:
671
- self._entries[row].length = spec.length
731
+ # Same as the bulk edit: the AOB pattern spec declares no width of
732
+ # its own, so the entry keeps the one it has.
733
+ entry.length = spec.length or entry.length
672
734
  self._rebuild()
673
735
 
674
736
  def _change_length(self, row: int) -> None:
@@ -678,7 +740,9 @@ class CheatTable(QWidget):
678
740
  "Length (bytes):",
679
741
  value=self._entries[row].length,
680
742
  minValue=1,
681
- maxValue=1024,
743
+ # An entry already wider than the cap can still be shrunk from here;
744
+ # it just can't grow past it.
745
+ maxValue=max(MAX_ENTRY_LENGTH, self._entries[row].length),
682
746
  )
683
747
  if not ok:
684
748
  return
@@ -736,6 +800,41 @@ class CheatTable(QWidget):
736
800
  QMessageBox.warning(self, "Import", f"Skipped a bad entry: {exc}")
737
801
 
738
802
 
803
+ # Ceiling for an entry's buffer width. Entries are promoted at the width of the
804
+ # value scanned for, which the scanner doesn't cap, so 1024 was too tight — but
805
+ # the poll worker allocates this many bytes per entry on every 100 ms tick, so
806
+ # an unbounded field turns one typo into a multi-gigabyte allocation the tick's
807
+ # blanket except swallows and retries forever. A megabyte is far past any real
808
+ # value and still cheap to read ten times a second.
809
+ MAX_ENTRY_LENGTH = 1_048_576
810
+
811
+
812
+ def _forget_value_read_as_another_type(entry: CheatEntry) -> None:
813
+ """Drop the cached value when ``new_spec`` can't read what produced it.
814
+
815
+ ``last_value`` and ``frozen_value`` hold whatever the *previous* spec
816
+ decoded — an int, a str, raw bytes. Nothing waits for a fresh poll tick
817
+ before the new spec is used on them, and a spec's ``format`` only accepts
818
+ what its own ``pytype`` produces: ``_fmt_bytes(1234)`` raises TypeError from
819
+ inside a Qt slot, and a frozen entry would be re-published to the poll
820
+ worker to write the old type's value through the new type's ``pytype``.
821
+ The next tick refills both, so forgetting them costs a single frame.
822
+
823
+ Unconditional: even a switch that keeps the ``pytype`` can change the
824
+ width, and a value decoded at the old one means nothing at the new. A
825
+ caller that still needs the bytes — the bulk edit writes a value in the
826
+ same pass — must capture them before calling.
827
+
828
+ The freeze is released with it. Leaving the box ticked with no target would
829
+ make the next poll tick adopt whatever the address happens to hold, pinning
830
+ a value the user never chose; a released box is visible and re-arming it is
831
+ one click.
832
+ """
833
+ entry.last_value = None
834
+ entry.frozen_value = None
835
+ entry.frozen = False
836
+
837
+
739
838
  def prompt_for_manual_entry(parent) -> Optional[CheatEntry]:
740
839
  """Sequential QInputDialog flow for the "Add Address Manually" button."""
741
840
  description, ok = QInputDialog.getText(
@@ -763,13 +862,16 @@ def prompt_for_manual_entry(parent) -> Optional[CheatEntry]:
763
862
  return None
764
863
  spec = find_spec(spec_label) or VALUE_TYPES[0]
765
864
 
865
+ # The AOB pattern spec declares a length of 0 (the scanner derives a match's
866
+ # width from the pattern), and an address added by hand has no pattern to
867
+ # measure — so ask for the width instead of minting a zero-byte buffer.
766
868
  length = spec.length
767
- if spec.accepts_length_override:
869
+ if spec.accepts_length_override or not spec.length:
768
870
  length, ok = QInputDialog.getInt(
769
871
  parent,
770
872
  "Add address",
771
873
  "Buffer length (bytes):",
772
- value=spec.length,
874
+ value=spec.length or 4,
773
875
  minValue=1,
774
876
  maxValue=1024,
775
877
  )
@@ -47,7 +47,12 @@ from PySide6.QtWidgets import (
47
47
  from PyMemoryEditor import AbstractProcess
48
48
 
49
49
  from ._widgets import parse_hex_address, parse_offsets, resolve_base_address
50
- from .value_types import VALUE_TYPES, ValueTypeSpec, find_spec
50
+ from .value_types import (
51
+ VALUE_TYPES,
52
+ ValueTypeSpec,
53
+ find_spec,
54
+ has_readable_width,
55
+ )
51
56
 
52
57
 
53
58
  # Child of "PyMemoryEditor" — surfaced by the Log Console via propagation.
@@ -216,6 +221,13 @@ class PointerChainDialog(QDialog):
216
221
 
217
222
  self._value_type_combo = QComboBox()
218
223
  for spec in VALUE_TYPES:
224
+ # The address is already known here, so the only spec that can't be
225
+ # offered is the one with no width of its own — an IDA pattern,
226
+ # which would read zero bytes. A regex is welcome: its width comes
227
+ # from the Length field and it renders the bytes as text up to the
228
+ # first NUL, which "String (UTF-8)" doesn't do.
229
+ if not has_readable_width(spec):
230
+ continue
219
231
  self._value_type_combo.addItem(spec.label)
220
232
  form.addRow("Read value as:", self._value_type_combo)
221
233
 
@@ -59,7 +59,12 @@ from ._widgets import (
59
59
  parse_hex_address,
60
60
  shutdown_worker_thread,
61
61
  )
62
- from .value_types import VALUE_TYPES, ValueTypeSpec, find_spec
62
+ from .value_types import (
63
+ VALUE_TYPES,
64
+ ValueTypeSpec,
65
+ find_spec,
66
+ has_readable_width,
67
+ )
63
68
 
64
69
 
65
70
  _LOG = logging.getLogger(__name__)
@@ -409,8 +414,13 @@ class PointerScanDialog(TearsDownOnClose, QDialog):
409
414
  # type into the cheat table on promotion.
410
415
  self._value_type_combo = QComboBox()
411
416
  for spec in VALUE_TYPES:
412
- if spec.is_pattern:
413
- continue # reading a value "as a pattern" is meaningless here
417
+ # The address is already known here, so the only spec that can't be
418
+ # offered is the one with no width of its own — an IDA pattern,
419
+ # which would read zero bytes. A regex is welcome: its width comes
420
+ # from the Length field and it renders the bytes as text up to the
421
+ # first NUL, which "String (UTF-8)" doesn't do.
422
+ if not has_readable_width(spec):
423
+ continue
414
424
  self._value_type_combo.addItem(spec.label)
415
425
  self._value_type_combo.currentTextChanged.connect(self._on_value_type_changed)
416
426
  form.addRow("Read value as:", self._value_type_combo)
@@ -22,7 +22,12 @@ from PySide6.QtCore import QThread, Signal
22
22
 
23
23
  from PyMemoryEditor import AbstractProcess, MemoryRegion, ScanTypesEnum
24
24
 
25
- from .scan_types import NextScanType, NO_VALUE_SCAN_TYPES, ScanType
25
+ from .scan_types import (
26
+ DELTA_SCAN_TYPES,
27
+ NextScanType,
28
+ NO_VALUE_SCAN_TYPES,
29
+ ScanType,
30
+ )
26
31
  from .value_types import parse_value, ValueTypeSpec
27
32
 
28
33
 
@@ -85,6 +90,7 @@ def build_scan_request(
85
90
  value_text: str,
86
91
  second_value_text: str = "",
87
92
  length_spin_value: Optional[int] = None,
93
+ previous_scan_length: Optional[int] = None,
88
94
  writeable_only: bool = False,
89
95
  with_value: bool = True,
90
96
  ) -> ScanRequest:
@@ -93,11 +99,19 @@ def build_scan_request(
93
99
 
94
100
  This is the pure core of ``ScannerPanel._build_request`` lifted out of the
95
101
  widget so the request-assembly rules (pattern short-circuit, the
96
- str-ignores-length override, range parsing, the no-value scan types) can be
97
- unit-tested without a ``QApplication``. The widget keeps only the bits that
98
- are genuinely UI: reading the fields and showing a ``QMessageBox`` on the
99
- ``ValueError`` raised here.
100
-
102
+ value-derived width for str / bytes, range parsing, the no-value scan types)
103
+ can be unit-tested without a ``QApplication``. The widget keeps only the
104
+ bits that are genuinely UI: reading the fields and showing a ``QMessageBox``
105
+ on the ``ValueError`` raised here.
106
+
107
+ :param length_spin_value: the Length field. Only the regex type reads it
108
+ (as ``byte_length``) — every other type derives its width from the spec
109
+ or from the value itself.
110
+ :param previous_scan_length: the width the scan that produced the current
111
+ results used. Only the no-value comparisons read it, and only for the
112
+ variable-width types, whose baseline is meaningless at another width.
113
+ (The ``*_BY`` deltas compare against the baseline too, but they are
114
+ rejected outright for those types — see below.)
101
115
  :raises ValueError: if a value/pattern fails to parse (message is
102
116
  user-facing — the caller picks the dialog title from ``spec.is_pattern``).
103
117
  """
@@ -116,19 +130,35 @@ def build_scan_request(
116
130
  writeable_only=writeable_only,
117
131
  )
118
132
 
119
- # String (UTF-8) ignores the length field: pass None so parse_value derives
120
- # the buffer width from the typed text's UTF-8 byte length. Byte Array still
121
- # honours the user-set override.
122
- length_override = (
123
- length_spin_value
124
- if spec.accepts_length_override and spec.pytype is not str
125
- else None
126
- )
133
+ # "Increased/Decreased value BY" adds the delta to the baseline, which only
134
+ # means anything for a number: on str/bytes ``prev + exp`` concatenates (so
135
+ # the comparison is never true) and ``prev - exp`` raises TypeError, which
136
+ # the refine worker swallows into "doesn't match". Either way every address
137
+ # is dropped and the user is told nothing, so reject the combination with a
138
+ # message instead. Checked *after* the pattern short-circuit above: the
139
+ # pattern specs are bytes-typed too, but they force EXACT regardless of the
140
+ # scan type passed, and the comparisons this message points at are disabled
141
+ # in pattern mode anyway.
142
+ if scan_type in DELTA_SCAN_TYPES and spec.pytype in (str, bytes):
143
+ raise ValueError(
144
+ "Increased/Decreased Value By adds a numeric amount to the previous "
145
+ "value, which doesn't apply to %s. Use Changed Value or Unchanged "
146
+ "Value to compare against the previous scan." % spec.label
147
+ )
127
148
 
128
- # Increased/Decreased/Changed/Unchanged compare current vs previous and need
129
- # no target value — just the value shape (type + length).
149
+ # Increased/Decreased/Changed/Unchanged compare the value read now against
150
+ # the one the *previous* scan recorded, so they need no target value — but
151
+ # they must re-read at the width that baseline was recorded with. Reading
152
+ # 16 bytes where the first scan recorded 4 yields "olá\0\0…" against "olá",
153
+ # which never compares equal, so every address would report as Changed.
154
+ # ``previous_scan_length`` carries that width for the variable-width types;
155
+ # the fixed-width types own theirs and ignore it.
130
156
  if scan_type in NO_VALUE_SCAN_TYPES:
131
- length = length_override if length_override is not None else spec.length
157
+ length = (
158
+ previous_scan_length
159
+ if spec.accepts_length_override and previous_scan_length
160
+ else spec.length
161
+ )
132
162
  return ScanRequest(
133
163
  spec=spec,
134
164
  length=int(length),
@@ -137,14 +167,22 @@ def build_scan_request(
137
167
  writeable_only=writeable_only,
138
168
  )
139
169
 
170
+ # Every scan that carries a value sizes its buffer from that value: the
171
+ # numeric types have a fixed width, and str / bytes derive theirs in
172
+ # parse_value (the text's UTF-8 byte length / the number of hex bytes
173
+ # entered). So no length override is passed here. Letting the Length field
174
+ # win could only break the scan — a width below the value's raises
175
+ # "byte string too long" from the fixed-width ctypes buffer, and a width
176
+ # above it NUL-pads the target, silently searching for "the value followed
177
+ # by zeros". Partial matching has its own value type (AOB Pattern).
140
178
  value: Any
141
179
  if scan_type in (ScanTypesEnum.VALUE_BETWEEN, ScanTypesEnum.NOT_VALUE_BETWEEN):
142
- lo, lo_len = parse_value(spec, value_text, length_override)
143
- hi, hi_len = parse_value(spec, second_value_text, length_override)
180
+ lo, lo_len = parse_value(spec, value_text)
181
+ hi, hi_len = parse_value(spec, second_value_text)
144
182
  length = max(lo_len, hi_len)
145
183
  value = (lo, hi)
146
184
  else:
147
- value, length = parse_value(spec, value_text, length_override)
185
+ value, length = parse_value(spec, value_text)
148
186
 
149
187
  if not with_value:
150
188
  value = None # Used by callers that only need spec/length/scan_type.
@@ -6,7 +6,7 @@ Inputs:
6
6
  * primary value (and a second value for "Value Between" / "Not Value Between")
7
7
  * value type
8
8
  * scan type
9
- * explicit byte length for str / bytes
9
+ * byte length (fixed per numeric type; derived from the value for str / bytes)
10
10
  * "writable regions only" toggle (passed to PyMemoryEditor as ``writeable_only``)
11
11
 
12
12
  Outputs (signals):
@@ -44,7 +44,30 @@ from .scan_types import (
44
44
  is_next_scan_type,
45
45
  )
46
46
  from .scan_worker import build_scan_request, ScanRequest
47
- from .value_types import VALUE_TYPES, find_spec
47
+ from .value_types import parse_value, ValueTypeSpec, VALUE_TYPES, find_spec
48
+
49
+
50
+ # Shown in the (read-only) Length field of String / Byte Array before a value
51
+ # has been entered: those types take their width from the value itself, so
52
+ # there is genuinely no number to report yet.
53
+ EMPTY_LENGTH_TEXT = "— (set by the value)"
54
+
55
+
56
+ def is_sized_by_value(spec: ValueTypeSpec) -> bool:
57
+ """True for the types whose buffer width comes from the value entered.
58
+
59
+ String (UTF-8) and Byte Array (Hex) only. The numeric types have a fixed
60
+ width, an IDA pattern derives one from the pattern, and a regex's Length
61
+ field is a genuine user-set ``byte_length`` — none of them may have their
62
+ width taken from a scan's value.
63
+ """
64
+ return spec.pytype in (str, bytes) and not spec.is_pattern
65
+
66
+
67
+ # The two scan types that take a second value; their width is max(lo, hi).
68
+ RANGE_SCAN_TYPES = frozenset(
69
+ (ScanTypesEnum.VALUE_BETWEEN, ScanTypesEnum.NOT_VALUE_BETWEEN)
70
+ )
48
71
 
49
72
 
50
73
  SCAN_TYPE_CHOICES = (
@@ -79,6 +102,17 @@ class ScannerPanel(QWidget):
79
102
  self._has_results = False
80
103
  self._busy = False
81
104
  self._initial_focus_done = False
105
+ # Width the scan that produced the current results ran at. The no-value
106
+ # comparisons (Increased / Changed / …) must re-read at exactly this
107
+ # width or they compare against a baseline recorded at another one; the
108
+ # Length readout can't stand in, since it tracks whatever value is in
109
+ # the field right now, which the user is free to edit between scans.
110
+ self._last_scan_length: Optional[int] = None
111
+ # Width of a scan that has been dispatched but hasn't landed yet. It is
112
+ # promoted above only when the owner reports results, so a scan that
113
+ # errors out or finds nothing leaves the values on screen described by
114
+ # the width they were actually read at.
115
+ self._pending_scan_length: Optional[int] = None
82
116
  self._build_ui()
83
117
  self._refresh_buttons()
84
118
 
@@ -119,14 +153,17 @@ class ScannerPanel(QWidget):
119
153
  self._value_edit = QLineEdit()
120
154
  self._value_edit.setPlaceholderText("e.g. 100 or 0x64 or Hello")
121
155
  self._value_edit.returnPressed.connect(self._on_value_submitted)
122
- # For String (UTF-8) the length is dictated by the typed text, so keep
123
- # the (disabled) length field in sync as the user types.
156
+ # For String (UTF-8) and Byte Array (Hex) the length is dictated by the
157
+ # typed value, so keep the (disabled) length field in sync as the user types.
124
158
  self._value_edit.textChanged.connect(self._on_value_text_changed)
125
159
  value_form.addRow("Value:", self._value_edit)
126
160
 
127
161
  self._second_value_edit = QLineEdit()
128
162
  self._second_value_edit.setPlaceholderText("Upper bound (for ranges only)")
129
163
  self._second_value_edit.returnPressed.connect(self._on_value_submitted)
164
+ # A range scan sizes with max(lo, hi), so the upper bound moves the
165
+ # readout just as the primary value does.
166
+ self._second_value_edit.textChanged.connect(self._on_value_text_changed)
130
167
  self._second_value_label = QLabel("Up to:")
131
168
  value_form.addRow(self._second_value_label, self._second_value_edit)
132
169
  self._second_value_edit.hide()
@@ -205,7 +242,7 @@ class ScannerPanel(QWidget):
205
242
 
206
243
  self._cancel_btn = QPushButton("Cancel scan")
207
244
  self._cancel_btn.setObjectName("danger")
208
- self._cancel_btn.clicked.connect(self.cancel_requested.emit)
245
+ self._cancel_btn.clicked.connect(self._on_cancel)
209
246
  buttons.addWidget(self._cancel_btn)
210
247
 
211
248
  layout.addWidget(buttons_box)
@@ -216,11 +253,34 @@ class ScannerPanel(QWidget):
216
253
  self._on_scan_type_changed(0)
217
254
 
218
255
  def set_has_results(self, has_results: bool) -> None:
256
+ """Report whether the results table currently holds anything.
257
+
258
+ Called by the owner once a scan has actually finished, which is what
259
+ makes it the right moment to adopt that scan's width as the baseline.
260
+ """
219
261
  self._has_results = has_results
262
+ if has_results:
263
+ if self._pending_scan_length:
264
+ self._last_scan_length = self._pending_scan_length
265
+ else:
266
+ self._last_scan_length = None
267
+ self._pending_scan_length = None
220
268
  self._refresh_buttons()
221
269
 
222
270
  def set_busy(self, busy: bool) -> None:
271
+ """Report whether a scan is running.
272
+
273
+ The falling edge ends the scan cycle, which is where a pending width
274
+ that was never adopted gets dropped. The owner emits it after the
275
+ completion signal (``finished`` follows ``finished_ok``), so a scan that
276
+ landed has already had its width promoted by ``set_has_results``; one
277
+ that errored out never will, and must not leave the width behind for
278
+ an unrelated later completion — an "Update Values" refresh reports
279
+ results too — to pick up.
280
+ """
223
281
  self._busy = busy
282
+ if not busy:
283
+ self._pending_scan_length = None
224
284
  self._refresh_buttons()
225
285
 
226
286
  def use_snapshot_cache(self) -> bool:
@@ -253,7 +313,7 @@ class ScannerPanel(QWidget):
253
313
 
254
314
  is_pattern = spec.is_pattern
255
315
  is_regex = spec.is_regex
256
- is_string = spec.pytype is str and not is_pattern
316
+ sized_by_value = is_sized_by_value(spec)
257
317
 
258
318
  # Pattern modes reuse the "Value" line for the pattern and force the
259
319
  # scan-type combo to EXACT (Bigger/Smaller/Between don't apply). The
@@ -261,15 +321,15 @@ class ScannerPanel(QWidget):
261
321
  # token count), but a *regex* has no inferable width, so its Length
262
322
  # field stays enabled and supplies search_by_pattern's byte_length.
263
323
  #
264
- # String (UTF-8) also locks the length field: the buffer width is the
265
- # UTF-8 byte length of the typed text (multi-byte aware), so letting the
266
- # user override it would only allow truncating or over-allocating the
267
- # value they entered. The field stays visible as a read-only readout
268
- # kept in sync by _sync_string_length / _on_value_text_changed.
269
- self._length_spin.setEnabled(
270
- (spec.accepts_length_override and not is_pattern and not is_string)
271
- or is_regex
272
- )
324
+ # Regex is the one spec whose width is genuinely the user's to set (its
325
+ # byte_length is the max match width, which nothing can infer). String /
326
+ # Byte Array mirror the value they were given — overriding that could
327
+ # only truncate it, which the fixed-width ctypes buffer rejects, or
328
+ # over-allocate it, which NUL-pads the target into a silent search for
329
+ # "the value followed by zeros". Everything else is fixed by its spec.
330
+ # The field stays visible as a read-only readout throughout, kept in
331
+ # sync by _sync_value_length / _on_value_text_changed.
332
+ self._length_spin.setEnabled(is_regex)
273
333
 
274
334
  if is_regex:
275
335
  self._value_edit.setPlaceholderText(
@@ -282,6 +342,13 @@ class ScannerPanel(QWidget):
282
342
  else:
283
343
  self._value_edit.setPlaceholderText("e.g. 100 or 0x64 or Hello")
284
344
 
345
+ # Every type but the value-sized pair owns a real number here, so clear
346
+ # the "no width yet" slot the previous type may have opened (0 would
347
+ # otherwise render as EMPTY_LENGTH_TEXT for e.g. "1 Byte (Int8)").
348
+ if not sized_by_value:
349
+ self._length_spin.setSpecialValueText("")
350
+ self._length_spin.setMinimum(1)
351
+
285
352
  if is_regex:
286
353
  # Length = the regex's max match width in bytes (byte_length); it
287
354
  # drives the chunk overlap so a match straddling a chunk boundary is
@@ -294,16 +361,22 @@ class ScannerPanel(QWidget):
294
361
  self._length_spin.setMaximum(1024)
295
362
  self._length_spin.setValue(1)
296
363
  self._length_spin.setSuffix(" bytes")
297
- elif is_string:
298
- # Length tracks the typed text — raise the ceiling so long strings
299
- # aren't visually clamped, then mirror the current text's byte size.
364
+ elif sized_by_value: # String (UTF-8) / Byte Array (Hex)
365
+ # Length tracks the typed value — raise the ceiling so long strings
366
+ # aren't visually clamped, then mirror the current value's byte size.
367
+ #
368
+ # Until a value has been entered there is no width to report, and
369
+ # the readout is read-only, so the user can't correct a number we
370
+ # invent. Open a 0 slot rendered as EMPTY_LENGTH_TEXT for that
371
+ # state rather than seeding the spec default (which would show
372
+ # "4 bytes" for an empty byte array and then jump to "1 byte" on
373
+ # the first hex digit) or keeping a width the previous type wrote.
374
+ self._length_spin.setMinimum(0)
375
+ self._length_spin.setSpecialValueText(EMPTY_LENGTH_TEXT)
300
376
  self._length_spin.setMaximum(2_147_483_647)
301
377
  self._length_spin.setSuffix(" bytes")
302
- self._sync_string_length()
303
- elif spec.accepts_length_override: # Byte Array (Hex)
304
- self._length_spin.setMaximum(1024)
305
- self._length_spin.setValue(max(4, self._length_spin.value()))
306
- self._length_spin.setSuffix(" bytes")
378
+ self._length_spin.setValue(0)
379
+ self._sync_value_length()
307
380
  else:
308
381
  self._length_spin.setMaximum(1024)
309
382
  self._length_spin.setValue(spec.length)
@@ -332,30 +405,62 @@ class ScannerPanel(QWidget):
332
405
  self._refresh_buttons()
333
406
 
334
407
  def _on_value_text_changed(self, text: str) -> None:
335
- # Only String (UTF-8) derives its length from the value text; every
336
- # other type owns its length field independently.
408
+ # _sync_value_length ignores the types that own their length field.
409
+ self._sync_value_length(text)
410
+
411
+ def _sync_value_length(self, text: Optional[str] = None) -> None:
412
+ """Mirror the byte size of the value text into the length field.
413
+
414
+ Matches ``parse_value``'s rules for the two variable-width types so the
415
+ read-only readout shows exactly the buffer width the scan will use: the
416
+ UTF-8 byte length for a string (byte length, not character count) and
417
+ the number of parsed hex bytes for a byte array.
418
+
419
+ The readout is exactly what the current value sizes to, and nothing
420
+ else: an empty field, or a half-typed byte array ("00 1"), reports no
421
+ width at all (EMPTY_LENGTH_TEXT) rather than a number no scan would
422
+ use. A range scan sizes with ``max(lo, hi)``, so both bounds count.
423
+ The "Next Scan" comparisons that carry no value of their own don't read
424
+ this field — they refine at ``_last_scan_length``, the width the scan
425
+ holding the current results actually ran at.
426
+
427
+ ``text`` is accepted (and ignored) so the method can sit directly on a
428
+ ``textChanged`` signal; the width always comes from reading the fields,
429
+ since either of the two can be the one that sets it.
430
+ """
431
+ del text # Both fields are read below; see the docstring.
337
432
  spec = find_spec(self._type_combo.currentText())
338
- if spec is not None and spec.pytype is str and not spec.is_pattern:
339
- self._sync_string_length(text)
433
+ # Only the value-sized types have a width to mirror; every other type
434
+ # owns the field (a fixed width, or the regex's editable match width)
435
+ # and must not have it overwritten from here.
436
+ if spec is None or not is_sized_by_value(spec):
437
+ return
340
438
 
341
- def _sync_string_length(self, text: Optional[str] = None) -> None:
342
- """Mirror the UTF-8 byte length of the value text into the length field.
439
+ texts = [self._value_edit.text()]
440
+ # Read the scan type rather than the widget's visibility: a child of a
441
+ # panel that hasn't been shown yet reports isVisible() False even after
442
+ # setVisible(True), which would silently drop the upper bound.
443
+ _, scan_type = SCAN_TYPE_CHOICES[self._scan_combo.currentIndex()]
444
+ if scan_type in RANGE_SCAN_TYPES:
445
+ texts.append(self._second_value_edit.text())
343
446
 
344
- Matches ``parse_value``'s str rule (byte length, not character count)
345
- so the read-only readout shows exactly the buffer width the scan uses.
346
- """
347
- if text is None:
348
- text = self._value_edit.text()
349
- self._length_spin.setValue(max(1, len(text.encode("utf-8"))))
447
+ length = 0
448
+ for candidate in texts:
449
+ try:
450
+ _, candidate_length = parse_value(spec, candidate)
451
+ except ValueError:
452
+ continue
453
+ length = max(length, candidate_length)
454
+
455
+ self._length_spin.setValue(length)
350
456
 
351
457
  def _on_scan_type_changed(self, index: int) -> None:
352
458
  _, scan_type = SCAN_TYPE_CHOICES[index]
353
- ranged = scan_type in (
354
- ScanTypesEnum.VALUE_BETWEEN,
355
- ScanTypesEnum.NOT_VALUE_BETWEEN,
356
- )
459
+ ranged = scan_type in RANGE_SCAN_TYPES
357
460
  self._second_value_edit.setVisible(ranged)
358
461
  self._second_value_label.setVisible(ranged)
462
+ # Entering or leaving a range changes which fields size the scan.
463
+ self._sync_value_length()
359
464
 
360
465
  # In pattern mode the Value field holds the AOB pattern and the
361
466
  # scan-type combo is forced to EXACT, so leave its value field alone.
@@ -393,6 +498,7 @@ class ScannerPanel(QWidget):
393
498
  value_text=self._value_edit.text(),
394
499
  second_value_text=self._second_value_edit.text(),
395
500
  length_spin_value=self._length_spin.value(),
501
+ previous_scan_length=self._last_scan_length,
396
502
  writeable_only=self._writable_check.isChecked(),
397
503
  with_value=with_value,
398
504
  )
@@ -415,24 +521,117 @@ class ScannerPanel(QWidget):
415
521
  return
416
522
  request = self._build_request()
417
523
  if request is not None:
524
+ self._pending_scan_length = self._dispatched_width(request)
418
525
  self.first_scan_requested.emit(request)
419
526
 
420
527
  def _on_next_scan(self) -> None:
421
528
  request = self._build_request()
422
529
  if request is not None:
530
+ # The refine rewrites every kept value at this width, so it becomes
531
+ # the baseline the next no-value comparison has to match — once it
532
+ # has actually run.
533
+ self._pending_scan_length = self._dispatched_width(request)
423
534
  self.next_scan_requested.emit(request)
424
535
 
536
+ def _dispatched_width(self, request: ScanRequest) -> int:
537
+ """Width the rows this request finds will have been read at.
538
+
539
+ Normally the request's own length. An IDA pattern is the exception: it
540
+ reports 0, because the scanner derives a match's width from the pattern
541
+ rather than from a field — so the width one hit occupies is the token
542
+ count, which is what a promoted row has to be read at.
543
+ """
544
+ if request.spec.is_pattern and not request.spec.is_regex:
545
+ return self._pattern_byte_length()
546
+ return request.length
547
+
548
+ def _on_cancel(self) -> None:
549
+ # Both workers still report results after a cancel — they break out of
550
+ # the loop and emit finished_ok with what they have — so what the
551
+ # partial results are worth depends on which scan was running.
552
+ #
553
+ # A refine re-read only the rows it reached at the new width; the rest
554
+ # still hold values recorded at the old one, so neither width describes
555
+ # the table and the previous one (which most rows match) stands.
556
+ #
557
+ # A first scan is the opposite: every address it did find, it found at
558
+ # its own width, and there is no earlier width to fall back on. Dropping
559
+ # it would send the next Changed/Unchanged refine to the spec default —
560
+ # 16 for a String scanned at 4 — which is the failure this whole branch
561
+ # exists to fix. _has_results tells the two apart: a first scan only
562
+ # runs when there are none yet.
563
+ if self._has_results:
564
+ self._pending_scan_length = None
565
+ self.cancel_requested.emit()
566
+
425
567
  def _on_update_values(self) -> None:
426
- request = self._build_request()
427
- if request is not None:
428
- self.update_values_requested.emit(request)
568
+ # A read-only refresh of the rows already on screen. RefineScanWorker
569
+ # applies no comparison when filter_only is False, so this needs no
570
+ # target value — and must not parse one: the Value box may be empty
571
+ # (a no-value scan type clears it outright), which would abort the
572
+ # refresh with "Invalid value" instead of refreshing.
573
+ spec, length = self.current_spec_and_length()
574
+ # The refresh re-reads and patches every row at this width, so it is the
575
+ # width the table holds once it lands — the same one it was scanned at,
576
+ # recorded here so that a width left behind by a request the owner
577
+ # rejected before the busy cycle began (an early return in its handler)
578
+ # is overwritten rather than adopted in its place.
579
+ self._pending_scan_length = length
580
+ self.update_values_requested.emit(
581
+ ScanRequest(
582
+ spec=spec,
583
+ length=length,
584
+ scan_type=ScanTypesEnum.EXACT_VALUE,
585
+ value=None,
586
+ writeable_only=self._writable_check.isChecked(),
587
+ )
588
+ )
429
589
 
430
590
  def current_spec_and_length(self):
431
- """Return the active (spec, length) pair for the Promote-to-Cheat-Table path."""
591
+ """Return the (spec, width) the rows currently on screen were read at.
592
+
593
+ Used by the Promote-to-Cheat-Table path and by the "Update Values"
594
+ refresh — both act on those rows, so both need the width their scan
595
+ ran at rather than anything the Value box says now. An IDA hit keeps
596
+ this spec: a pattern-typed entry reads and writes correctly now (see
597
+ ``parse_value_for_write``), and only its width has to come from the
598
+ pattern, since the spec declares none.
599
+ """
432
600
  spec = find_spec(self._type_combo.currentText())
433
601
  if spec is None:
434
602
  spec = VALUE_TYPES[0]
603
+ # An IDA pattern has no Length field and a spec length of 0 — the width
604
+ # of one match is the pattern's own, one token per byte. Prefer the
605
+ # width the scan ran at: the Value box stays editable in pattern mode,
606
+ # so re-deriving it here would read at a width the hits were never
607
+ # found at, which is the staleness _last_scan_length exists to avoid.
608
+ if spec.is_pattern and not spec.is_regex:
609
+ return spec, self._last_scan_length or self._pattern_byte_length()
610
+
611
+ # The rows being promoted were read at the width their scan ran at, so
612
+ # that is the width the cheat entry has to keep. The Length readout
613
+ # can't stand in: it follows the Value box, which a no-value scan type
614
+ # clears outright (a 4-byte "olá" scan would promote at the spec's 16,
615
+ # and the entry would read 12 bytes of neighbouring memory into the
616
+ # cell on every poll tick).
617
+ if is_sized_by_value(spec) and self._last_scan_length:
618
+ return spec, self._last_scan_length
619
+
435
620
  length = (
436
621
  self._length_spin.value() if spec.accepts_length_override else spec.length
437
622
  )
438
- return spec, int(length)
623
+ # No scan has run yet and no value is entered (readout 0): a cheat entry
624
+ # can't have a zero-width buffer, so the spec default stands in.
625
+ return spec, int(length) or spec.length
626
+
627
+ def _pattern_byte_length(self) -> int:
628
+ """Width of one match of the AOB pattern currently in the Value field."""
629
+ from PyMemoryEditor.util.pattern import compile_pattern
630
+
631
+ try:
632
+ return max(1, compile_pattern(self._value_edit.text().strip())[1])
633
+ except ValueError:
634
+ # The results being promoted came from a pattern that compiled, so
635
+ # this only happens if the field was edited afterwards. One byte is
636
+ # a harmless entry the user can widen from the cheat table.
637
+ return 1
@@ -35,6 +35,15 @@ class ValueTypeSpec:
35
35
  # ``byte_length`` (the number of bytes one match consumes) that
36
36
  # ``search_by_pattern`` requires for regex input.
37
37
  is_regex: bool = False
38
+ # How cell text becomes the bytes to write back, for the types whose
39
+ # ``parse`` answers a different question — "what am I searching for?"
40
+ # rather than "what value is this?". Receives the text and the bytes last
41
+ # read at the address, so a wildcard can mean "leave that byte alone".
42
+ # ``None`` means ``parse`` already answers both, which is the case for
43
+ # every type that isn't a pattern.
44
+ parse_write: Optional[
45
+ Callable[[str, Optional[bytes], Optional[int]], Any]
46
+ ] = None
38
47
 
39
48
 
40
49
  def _parse_bool(text: str) -> bool:
@@ -86,6 +95,23 @@ def _parse_bytes(text: str) -> bytes:
86
95
  raise ValueError(f"Invalid byte array: {exc}")
87
96
 
88
97
 
98
+ def _parse_str(text: str) -> str:
99
+ """Return the value text verbatim, rejecting an empty one.
100
+
101
+ An empty string sizes to a 1-byte NUL buffer, so *scanning* for it matches
102
+ every zeroed byte in the target, and *writing* it is a no-op (``prepare_write``
103
+ truncates to the value and never pads). Neither is what the user meant.
104
+ ``_parse_bytes`` already rejects its own empty input; this keeps the two
105
+ variable-width types consistent.
106
+
107
+ The wording stays neutral because this runs on the cheat table's write
108
+ paths too, not only on a scan.
109
+ """
110
+ if not text:
111
+ raise ValueError("Empty value.")
112
+ return text
113
+
114
+
89
115
  def _parse_pattern(text: str) -> str:
90
116
  """Validate an IDA-style AOB pattern and return it verbatim.
91
117
 
@@ -143,6 +169,69 @@ def _parse_regex(text: str) -> bytes:
143
169
  return pattern
144
170
 
145
171
 
172
+ def _parse_pattern_write(
173
+ text: str, current: Optional[bytes], length_override: Optional[int] = None
174
+ ) -> bytes:
175
+ """Turn an IDA pattern typed into a value cell into the bytes to write.
176
+
177
+ ``_parse_pattern`` answers the scanner's question and hands back the
178
+ pattern *text*, which is not something that can be written anywhere — the
179
+ cell would store the ASCII spelling of the hex it displays. Here the same
180
+ tokens are resolved to the bytes they name.
181
+
182
+ A ``?`` keeps whatever byte is already at that offset. That mirrors its
183
+ search meaning ("any byte") and is what makes a signature patchable: you
184
+ name the bytes you mean to change and leave the operands alone. It needs
185
+ the current contents, so a wildcard only works once the entry has been
186
+ read at least one poll tick.
187
+ """
188
+ from PyMemoryEditor.util.pattern import tokenize_pattern
189
+
190
+ tokens = tokenize_pattern(text)
191
+ wildcards = [index for index, token in enumerate(tokens) if token is None]
192
+
193
+ if wildcards:
194
+ # `current` is whatever the entry's spec produced when it was last
195
+ # polled, and a type change doesn't wait for a fresh tick — so it can
196
+ # still be the int/str/float the *previous* type read. Anything but
197
+ # bytes is treated as "nothing read yet" rather than reaching len()
198
+ # and raising a TypeError the callers' `except ValueError` won't catch.
199
+ if not isinstance(current, (bytes, bytearray)):
200
+ current = None
201
+ if current is None:
202
+ raise ValueError(
203
+ "A '?' keeps the byte that is already there, so it can't be "
204
+ "used before the value has been read at least once."
205
+ )
206
+ if len(current) < wildcards[-1] + 1:
207
+ raise ValueError(
208
+ "'?' at byte %d has nothing to keep: the last read of this "
209
+ "address returned %d byte(s). Wait for the next refresh, or "
210
+ "spell the byte out." % (wildcards[-1] + 1, len(current))
211
+ )
212
+
213
+ return bytes(
214
+ current[index] if token is None else token # type: ignore[index]
215
+ for index, token in enumerate(tokens)
216
+ )
217
+
218
+
219
+ def _parse_regex_write(
220
+ text: str, current: Optional[bytes], length_override: Optional[int] = None
221
+ ) -> bytes:
222
+ """Turn a value cell's text into bytes for a regex-typed entry.
223
+
224
+ A regex names a *set* of byte strings, so the pattern itself can't be
225
+ written back. What the cell shows is the text read at the address
226
+ (``_fmt_regex_match``), so an edit is taken literally — the same rule as
227
+ String (UTF-8) — and writes exactly the characters typed.
228
+ """
229
+ del current, length_override # A literal write depends on neither.
230
+ if not text:
231
+ raise ValueError("Empty value.")
232
+ return text.encode("utf-8")
233
+
234
+
146
235
  def _fmt_bytes(value: bytes) -> str:
147
236
  if value is None:
148
237
  return ""
@@ -231,7 +320,7 @@ VALUE_TYPES = (
231
320
  "String (UTF-8)",
232
321
  str,
233
322
  16,
234
- lambda s: s,
323
+ _parse_str,
235
324
  lambda v: "" if v is None else str(v),
236
325
  accepts_length_override=True,
237
326
  ),
@@ -254,6 +343,7 @@ VALUE_TYPES = (
254
343
  lambda v: "" if v is None else (v if isinstance(v, str) else _fmt_bytes(v)),
255
344
  accepts_length_override=False,
256
345
  is_pattern=True,
346
+ parse_write=_parse_pattern_write,
257
347
  ),
258
348
  # Text-regex scan — the "Value" input becomes a string regex (e.g.
259
349
  # ``Player[0-9]+``) UTF-8 encoded into the bytes pattern, and the Length
@@ -269,6 +359,7 @@ VALUE_TYPES = (
269
359
  accepts_length_override=True,
270
360
  is_pattern=True,
271
361
  is_regex=True,
362
+ parse_write=_parse_regex_write,
272
363
  ),
273
364
  )
274
365
 
@@ -310,3 +401,83 @@ def parse_value(
310
401
  # under-allocating would silently truncate the value the user typed.
311
402
  length = max(1, len(value.encode("utf-8")))
312
403
  return value, length
404
+
405
+
406
+ def _written_width(value: Any) -> int:
407
+ """Bytes ``value`` occupies once written, or 0 when its spec sets the size.
408
+
409
+ ``str`` is measured encoded: the entry's width is a byte count, so counting
410
+ characters would let a multibyte value overflow it.
411
+ """
412
+ if isinstance(value, str):
413
+ return len(value.encode("utf-8"))
414
+ if isinstance(value, (bytes, bytearray)):
415
+ return len(value)
416
+ return 0
417
+
418
+
419
+ def has_readable_width(spec: ValueTypeSpec) -> bool:
420
+ """True when the spec can size a read at an address the caller already has.
421
+
422
+ Every spec but the IDA pattern can: the numeric types declare a width, and
423
+ str / bytes / regex take one from the caller. An IDA pattern derives a
424
+ match's width from the pattern itself, so at a bare address it has none —
425
+ it declares a length of 0 *and* refuses a length override, which is exactly
426
+ the combination this tests. Offering it where the address is already known
427
+ would read zero bytes and display nothing forever.
428
+
429
+ Note this says nothing about a *cheat entry*, which carries its own width
430
+ and so can hold an IDA pattern quite happily (see ``parse_value_for_write``).
431
+ """
432
+ return spec.length > 0 or spec.accepts_length_override
433
+
434
+
435
+ def parse_value_for_write(
436
+ spec: ValueTypeSpec,
437
+ text: str,
438
+ length_override: Optional[int] = None,
439
+ current: Optional[bytes] = None,
440
+ ) -> Tuple[Any, int]:
441
+ """Parse ``text`` as the value to **write** at an address.
442
+
443
+ :func:`parse_value` answers the scanner's question ("what am I looking
444
+ for?"). For every type but the two pattern ones that is the same answer,
445
+ but a pattern's ``parse`` yields search syntax — an IDA pattern's text, or
446
+ a regex — which is not a value any address can hold. Those specs supply a
447
+ ``parse_write`` that resolves the cell text to concrete bytes instead,
448
+ given ``current``: the bytes last read there, which a ``?`` wildcard keeps.
449
+
450
+ Used by the cheat table, whose cells are read *and* written; the scanner
451
+ only ever searches, so it stays on :func:`parse_value`.
452
+
453
+ The returned width follows :func:`parse_value`'s rule — an explicit
454
+ ``length_override`` wins for the types that accept one. Writing a value to
455
+ an entry must not resize it: the cheat table stores this width back onto the
456
+ entry, and the entry's width is how many bytes it *reads* on every poll
457
+ tick, which the user set and the write has no business shrinking.
458
+ """
459
+ if spec.parse_write is None:
460
+ value, length = parse_value(spec, text, length_override)
461
+ else:
462
+ value = spec.parse_write(text, current, length_override)
463
+ length = (
464
+ max(1, int(length_override))
465
+ if spec.accepts_length_override and length_override is not None
466
+ else max(1, _written_width(value))
467
+ )
468
+
469
+ # One guard for every path that reaches an address. An entry's length is a
470
+ # *byte* width — it is the bufflength each poll tick reads — while
471
+ # prepare_write treats it as a hard cap and truncates past it, counting
472
+ # characters for str. So a wider value was written short with no word said
473
+ # while the cell kept showing all of it, and a multibyte str could slip the
474
+ # other way: "ábc" is 3 characters but 4 bytes, one past a 3-byte entry.
475
+ # Measuring what actually goes on the wire covers both.
476
+ written = _written_width(value)
477
+ if length_override is not None and written > length_override:
478
+ raise ValueError(
479
+ "Value is %d bytes but the entry holds %d. Widen the entry first, "
480
+ "or the extra would be dropped without warning."
481
+ % (written, length_override)
482
+ )
483
+ return value, length
@@ -38,6 +38,7 @@ from .libsystem import PROC_ALL_PIDS, libsystem, mach_error_message, mach_task_s
38
38
  from .types import (
39
39
  KERN_INVALID_ADDRESS,
40
40
  KERN_INVALID_ARGUMENT,
41
+ KERN_MEMORY_ERROR,
41
42
  KERN_NO_ACCESS,
42
43
  KERN_PROTECTION_FAILURE,
43
44
  KERN_SUCCESS,
@@ -294,10 +295,20 @@ def get_memory_regions(task: int, pid: int = 0) -> Generator[MemoryRegion, None,
294
295
  # KERN_NO_ACCESS / KERN_INVALID_ARGUMENT can also surface for guard pages and
295
296
  # freshly-unmapped pages on modern macOS; treating them as fatal aborts a scan
296
297
  # that should just skip the page.
298
+ #
299
+ # KERN_MEMORY_ERROR is the same story one level down: the pager backing a page
300
+ # declined to produce its data. The kernel header calls that failure
301
+ # "temporary" in as many words, in explicit contrast with the permanent
302
+ # KERN_MEMORY_FAILURE beside it (the pair is spelled out in types.py). It is
303
+ # what file-backed, read-only mappings (code segments, dylibs, the dyld shared
304
+ # cache) return, so any scan that walks them — every pattern scan, and any value
305
+ # scan with "writable regions only" turned off — hits it routinely, and used to
306
+ # abort on the first one.
297
307
  _PAGE_GONE_KRS = (
298
308
  KERN_INVALID_ADDRESS,
299
309
  KERN_NO_ACCESS,
300
310
  KERN_INVALID_ARGUMENT,
311
+ KERN_MEMORY_ERROR,
301
312
  )
302
313
 
303
314
 
@@ -65,6 +65,18 @@ KERN_PROTECTION_FAILURE = 2
65
65
  KERN_INVALID_ARGUMENT = 4
66
66
  KERN_FAILURE = 5
67
67
  KERN_NO_ACCESS = 8
68
+ # These two are neighbours in mach/kern_return.h and are the line between a page
69
+ # a scan may walk past and one it may not, so they are defined together:
70
+ #
71
+ # 9 "the target address refers to a memory object that has been destroyed.
72
+ # This failure is permanent."
73
+ # 10 "the memory object indicated that the data could not be returned. This
74
+ # failure may be temporary; future attempts to access this same data may
75
+ # succeed, as defined by the memory object."
76
+ #
77
+ # Only the second belongs in functions._PAGE_GONE_KRS.
78
+ KERN_MEMORY_FAILURE = 9
79
+ KERN_MEMORY_ERROR = 10
68
80
 
69
81
 
70
82
  class vm_region_basic_info_64(Structure):
@@ -26,7 +26,7 @@ The function returns a compiled ``re.Pattern[bytes]`` ready to be used with
26
26
  """
27
27
 
28
28
  import re
29
- from typing import Pattern, Tuple, Union
29
+ from typing import List, Optional, Pattern, Tuple, Union
30
30
 
31
31
 
32
32
  PatternLike = Union[str, bytes, "re.Pattern[bytes]"]
@@ -84,33 +84,58 @@ def compile_pattern(
84
84
  "compiled re.Pattern, not %r" % type(pattern).__name__
85
85
  )
86
86
 
87
- tokens = pattern.split()
88
- if not tokens:
89
- raise ValueError("Empty pattern.")
87
+ tokens = tokenize_pattern(pattern)
90
88
 
91
89
  parts = []
92
90
  for token in tokens:
93
- if token in ("?", "??"):
91
+ if token is None:
94
92
  # Single-byte wildcard. ``.`` together with re.DOTALL matches any
95
93
  # byte 0x00-0xFF without special-casing 0x0A.
96
94
  parts.append(b".")
97
95
  continue
96
+ # Escape the byte so e.g. 0x5C (backslash) or 0x28 ('(') don't get
97
+ # interpreted as regex meta chars.
98
+ parts.append(re.escape(bytes((token,))))
99
+
100
+ return re.compile(b"".join(parts), re.DOTALL), len(tokens)
101
+
102
+
103
+ def tokenize_pattern(pattern: str) -> List[Optional[int]]:
104
+ """Split an IDA-style hex pattern into one entry per byte.
105
+
106
+ Each entry is the byte's value, or ``None`` for a ``?`` / ``??`` wildcard.
107
+ ``compile_pattern`` turns these into a regex; the app's cheat table uses
108
+ them to write a signature back, where a wildcard means "leave the byte
109
+ that is already there alone". Both need the same token rules and the same
110
+ error messages, so they share this.
111
+
112
+ :raises ValueError: on an empty pattern or a malformed token.
113
+ """
114
+ tokens = pattern.split()
115
+ if not tokens:
116
+ raise ValueError("Empty pattern.")
117
+
118
+ parsed: List[Optional[int]] = []
119
+ for token in tokens:
120
+ if token in ("?", "??"):
121
+ parsed.append(None)
122
+ continue
98
123
  if len(token) != 2:
99
124
  raise ValueError(
100
125
  "Pattern token %r is not two hex digits or a '?' wildcard. "
101
126
  "Example of a valid pattern: '48 8B ? ? 00'." % token
102
127
  )
103
128
  try:
104
- byte = bytes.fromhex(token)
129
+ parsed.append(bytes.fromhex(token)[0])
105
130
  except ValueError as exc:
106
131
  raise ValueError(
107
132
  "Pattern token %r is not valid hex: %s" % (token, exc)
108
133
  )
109
- # Escape the byte so e.g. 0x5C (backslash) or 0x28 ('(') don't get
110
- # interpreted as regex meta chars.
111
- parts.append(re.escape(byte))
112
-
113
- return re.compile(b"".join(parts), re.DOTALL), len(tokens)
134
+ return parsed
114
135
 
115
136
 
137
+ # tokenize_pattern is deliberately absent: it exists so the scan and write
138
+ # paths share one set of token rules, and nothing outside the package consumes
139
+ # it. Exporting it would promise a documented, supported surface that neither
140
+ # the guide nor the API reference describes.
116
141
  __all__ = ("compile_pattern", "PatternLike")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: PyMemoryEditor
3
- Version: 2.1.0
3
+ Version: 2.2.0
4
4
  Summary: Read, write and scan process memory in a few lines of Python — Cheat Engine-style scans, pointer chains and AOB search on Windows, Linux and macOS.
5
5
  Project-URL: Homepage, https://github.com/JeanExtreme002/PyMemoryEditor
6
6
  Project-URL: Documentation, https://pymemoryeditor.readthedocs.io
@@ -138,32 +138,6 @@ That's it — read, write or scan another process in three lines, the same way o
138
138
 
139
139
  ---
140
140
 
141
- ## What's inside
142
-
143
- ### 🐍 The Python library
144
-
145
- Full control over another process's memory — in a few lines of Python:
146
-
147
- - ✅ **Read & write** values (`int`, `float`, `bool`, `str`, `bytes`)
148
- - 🔍 **Value scan** with eight comparison modes
149
- - 🎯 **Pattern scan** (IDA-style AOB & regex)
150
- - 🔗 **Pointer chains** + a live `RemotePointer` handle
151
- - 🧭 **Pointer scan** — find static pointers that survive ASLR
152
- - 🗺️ **Memory map**, **modules**, **threads**
153
- - 🧱 **Allocate & free** remote memory (Windows / macOS)
154
-
155
- ### 🖥️ The bundled GUI app
156
-
157
- All the library's power — no code required:
158
- - ⚡ **Zero setup** — attach to a process and start scanning in seconds
159
- - 🧲 **Refine workflow** — First Scan → Next Scan with live visual feedback
160
- - 📋 **Cheat table** — freeze / write values on the fly, JSON import/export
161
- - 🔬 **Hex viewer** — browse raw memory and write back inline
162
- - 🧩 **Pointer scan UI** — scan, export & rescan across sessions with a few clicks
163
- - 🎨 **One-click access** — every feature at your fingertips, no code needed
164
-
165
- ---
166
-
167
141
  ## 📖 Documentation
168
142
 
169
143
  Full documentation lives at **[pymemoryeditor.readthedocs.io](https://pymemoryeditor.readthedocs.io)** — installation, the Cheat Engine workflow, every method and parameter, the GUI app guide, platform notes and troubleshooting.
@@ -1,4 +1,4 @@
1
- PyMemoryEditor/__init__.py,sha256=NcdtGiiw7F8_CkPcd9eelPSp2xr-1SHFl0bggsbdAA4,3234
1
+ PyMemoryEditor/__init__.py,sha256=7y6oSpnE3E7iBrwEFhKwUq3cE36-hrNyK1gWL8xY9do,3234
2
2
  PyMemoryEditor/__main__.py,sha256=xukvvLjQ0BnYnnxMZda1cvyZ-BurSUk8dAGQybIqMD4,95
3
3
  PyMemoryEditor/enums.py,sha256=q_SWQYgh4F7yyAa7sZvNo2Sw91TSm3aTqPfTA7OqldM,322
4
4
  PyMemoryEditor/app/__init__.py,sha256=DGCYWpqGKPSeBa7cvzL7GmmhIfDDgF6vtim0VtLcfOo,357
@@ -8,30 +8,30 @@ PyMemoryEditor/app/_widgets.py,sha256=Xsu3DXUtlUa_zUR4WyXt7bGDyvKr9uKYY7Ki7lLPXq
8
8
  PyMemoryEditor/app/application.py,sha256=1S98I-wkccq1-8to6aLLlburgDvvmapbCQplk_ALEe8,16830
9
9
  PyMemoryEditor/app/cheat_entry.py,sha256=KOLcams-xkVEkV7rfrzhEI7IrsVvmGwsIr8cGcWdLM0,2841
10
10
  PyMemoryEditor/app/cheat_poll_worker.py,sha256=clkUklvuOVxbPfo_EJRjs8dfj_4RQk-3uusYhLcTtYM,12281
11
- PyMemoryEditor/app/cheat_table.py,sha256=vkQjgf1SFuVgMQ8e5h3Gn1NUN8asq9EwQbmRACD3gWk,34813
11
+ PyMemoryEditor/app/cheat_table.py,sha256=TzZ4F2r_k19KQXdi_xOBPud0h-601v9nRaDIFwlTxJY,40843
12
12
  PyMemoryEditor/app/log_console_dialog.py,sha256=VpYXxECepcbrtcKNheO0xwSoB63RsP_s7DuQnzyDf_g,7153
13
13
  PyMemoryEditor/app/main_window.py,sha256=H-TjgY77PTWYMN-TYOJFAGAq3SnsoI4M1w-B3wpvx-c,41619
14
14
  PyMemoryEditor/app/memory_map_dialog.py,sha256=WBhitNAShpektV8o6hUgxroc7T_BnRhQdeX3OhHdy4E,23236
15
15
  PyMemoryEditor/app/memory_viewer_dialog.py,sha256=DoR5poOu0xNCI1myOv9K-I-1Hx1PVFZDSUrDFzYZZiQ,12472
16
16
  PyMemoryEditor/app/modules_dialog.py,sha256=AaEnTgYC1cP-YJ1XUrDsw1eaF1cE16YRlzTjoTvJMMw,11715
17
17
  PyMemoryEditor/app/open_process_dialog.py,sha256=KtqxW16GqAwMVcTKZ6oj3v--eLagEbwWMuXWIarVIKo,17139
18
- PyMemoryEditor/app/pointer_chain_dialog.py,sha256=mKFgWkrHyVVaPkYjzHy2khCWQfGeUroPmGa1gTjowgw,19974
19
- PyMemoryEditor/app/pointer_scan_dialog.py,sha256=kzZjsWopIKC7xq6X5Rmgp_qUJbJql0RPoWeAH-1hLqQ,38241
18
+ PyMemoryEditor/app/pointer_chain_dialog.py,sha256=dUt832Wa3ruzWYpL-WDT_RiAQd1AhsdQmHSC5hHbysI,20461
19
+ PyMemoryEditor/app/pointer_scan_dialog.py,sha256=p-4p919YWoE0qXCMbnHKFlAD7ylPac0SaMWJrqXoyY0,38617
20
20
  PyMemoryEditor/app/results_view.py,sha256=mrCJquUL3vJwlka5an8_YUXr8ycWgav4uO4c4pxTktk,11763
21
21
  PyMemoryEditor/app/scan_types.py,sha256=QYAERatT169A3QhXYzu1MS5hclHGJ9tbB3BYsP_q_-4,2078
22
- PyMemoryEditor/app/scan_worker.py,sha256=Tf_87Ad__OimeUDzTnBSKYMnlrVxomGOGQh6NnDnvNQ,17160
23
- PyMemoryEditor/app/scanner_panel.py,sha256=Z4BcN4WYL8_LVZ_7lUGZcqsCSCcTgAR3PNo-FNw8wjQ,18381
22
+ PyMemoryEditor/app/scan_worker.py,sha256=9t4f-ih-G2MhU9vrtiud9_mzWCerv9Ce83PNkq_XDCI,19499
23
+ PyMemoryEditor/app/scanner_panel.py,sha256=J3jBlPEPYp1L6Qd1CkEitHYP5aTlgCsxgtAZizftmJw,29352
24
24
  PyMemoryEditor/app/threads_dialog.py,sha256=HnPS7Mte6CboM0DaoqkTqnEg8Ck65L1FKW-eX8m2OEA,7509
25
- PyMemoryEditor/app/value_types.py,sha256=3Yq5ms7mb31nLKsvVO8VyyxpEdp6pTdsK1j3ul8ayek,10621
25
+ PyMemoryEditor/app/value_types.py,sha256=eCImnr8nJEFcCBLTBD3PzmV1Ms4irQnurZIguVZUkho,18383
26
26
  PyMemoryEditor/linux/functions.py,sha256=raBILyavPlliEzM4r3z6qEcOWG_G7GUd_d1aWHF9K5U,21681
27
27
  PyMemoryEditor/linux/libc.py,sha256=U4yOpHJ48OAaDxcVk_s57GGXlvmyakInwM2liegf3OA,1431
28
28
  PyMemoryEditor/linux/process.py,sha256=_PafbkQHAcwsQlbwcUFd6ERHN_c4wQ3Qcgyt82Gz2Zw,8636
29
29
  PyMemoryEditor/linux/types.py,sha256=sOgO-s9oqKrKKCHMG4N-Pe7sNw49V06PeMENSNnNFl8,1924
30
30
  PyMemoryEditor/macos/__init__.py,sha256=85mOA4U5i9EW2r1ggKqRJkqRq0e9bV3I2HdfHIvox10,72
31
- PyMemoryEditor/macos/functions.py,sha256=C9_Cg4njd_1pCz-6cDNosnLN1DSRIeF4AK58WNyyNNc,42279
31
+ PyMemoryEditor/macos/functions.py,sha256=zL8jylondbDAj7UUuLmU8Fg8wVTCWkmEEpTzAu_384c,42894
32
32
  PyMemoryEditor/macos/libsystem.py,sha256=H9p72IqO0C5CrMFCAjnY_iQCzi_AQgw6ZN6CXsopceo,8683
33
33
  PyMemoryEditor/macos/process.py,sha256=06zgLaTKIyc1DQZFgS-9lqT2imB_IYFTOs4xEfSgftg,13259
34
- PyMemoryEditor/macos/types.py,sha256=gttewVsWwwQKzhOhMkkjUTg0_ipH3kKkkQhxmv7oiUE,4855
34
+ PyMemoryEditor/macos/types.py,sha256=bwPbYRZAOflITeNy0jeEXqFu-M5SVYkaFSsbyxz_K2w,5437
35
35
  PyMemoryEditor/process/__init__.py,sha256=Xh5jngBqZyNIA9y3uwE49LxK8uH8YYsu6qotx1mP_WE,213
36
36
  PyMemoryEditor/process/abstract.py,sha256=hv7eZyl6izTxz9z48X44QLh3g4fQdIBqVlrp8dJBnV0,53835
37
37
  PyMemoryEditor/process/errors.py,sha256=485SXkidg9_E_KjuhuF6AsQNuFZlY-Ag-sG1C6NsGMw,2054
@@ -45,7 +45,7 @@ PyMemoryEditor/process/thread_info.py,sha256=GeIpfL9TD0YVV75vlBlCUlseCDUNsTDKmxz
45
45
  PyMemoryEditor/process/util.py,sha256=qwh3agrj8B_EDu5LsfMxKFhFSp9YDafXkoUuOUV7owI,3160
46
46
  PyMemoryEditor/util/__init__.py,sha256=Lux1_4twAhkzdWDV4xt4INmshKXZAHje_NU2WuwQhoE,512
47
47
  PyMemoryEditor/util/convert.py,sha256=CnnJ0wTc8oN5kliAaH4IZJtxI1J0_hS7YKFyVuNyMWU,14399
48
- PyMemoryEditor/util/pattern.py,sha256=d8PLbTCMwpPiO1HF0tJ0WNXHQ0VG33dJvqAHLGMExf0,4321
48
+ PyMemoryEditor/util/pattern.py,sha256=OKcnTv5v1ny62dutwd850L-yQJMLa8fXybcbwdivcdg,5384
49
49
  PyMemoryEditor/util/scan.py,sha256=ZCQP2g_ANFdHNmhXf2CayV8zB44IAqHsG2lA7REMKF0,19811
50
50
  PyMemoryEditor/util/scan_numpy.py,sha256=osqZh7hfu9meKGXJPROLqzQ-UdSrCduqkv7EzUfVDp4,5889
51
51
  PyMemoryEditor/win32/functions.py,sha256=v-OAHBJZEcmLTCbRuqPpprik_CuvYVEuyVH-POAXlM0,34204
@@ -59,8 +59,8 @@ PyMemoryEditor/win32/enums/process_operations.py,sha256=wn-aMl01VVg-2rFavR9szBNR
59
59
  PyMemoryEditor/win32/enums/standard_access_rights.py,sha256=VGWlTagW4hvbm2AjZ4NK56ZrQwRmEl9Kz9GSCG8o-HI,738
60
60
  PyMemoryEditor/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
61
61
  PyMemoryEditor/app/assets/icon.svg,sha256=T7OqR7ldqC_dK2Ib2ntsH2rWO-Z5RzNK4WdD1wFwgM4,7132
62
- pymemoryeditor-2.1.0.dist-info/METADATA,sha256=M4v5aHIbeFv2QZsQIaULx1z8BQO7ElVZBGZx87NBF5A,9622
63
- pymemoryeditor-2.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
64
- pymemoryeditor-2.1.0.dist-info/entry_points.txt,sha256=ct14npP5HRKCHdq6-n83KBFi5QwQNSCsWE3MmGaWbzY,75
65
- pymemoryeditor-2.1.0.dist-info/licenses/LICENSE,sha256=YkOx6Nvkavq36bgiAc3eF85SBG0Qu14KMAvxmv6Ssjc,1089
66
- pymemoryeditor-2.1.0.dist-info/RECORD,,
62
+ pymemoryeditor-2.2.0.dist-info/METADATA,sha256=hiAocF8rEWox6UUUsnI_PKGgDLD5cbByM03DxF1PofY,8540
63
+ pymemoryeditor-2.2.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
64
+ pymemoryeditor-2.2.0.dist-info/entry_points.txt,sha256=ct14npP5HRKCHdq6-n83KBFi5QwQNSCsWE3MmGaWbzY,75
65
+ pymemoryeditor-2.2.0.dist-info/licenses/LICENSE,sha256=YkOx6Nvkavq36bgiAc3eF85SBG0Qu14KMAvxmv6Ssjc,1089
66
+ pymemoryeditor-2.2.0.dist-info/RECORD,,