From 1ad73c9ac9cd20c08dcfefa4f9e3736e6758d026 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Thu, 27 Aug 2026 01:30:27 -0300 Subject: [PATCH 01/18] fix(app): infer the byte-array scan width from the value entered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scanning for a Byte Array (Hex) required the user to keep the Length field in sync with the bytes they typed, and got both failure modes wrong when they didn't: * a value wider than Length was squeezed into a fixed-width ctypes buffer and the scan died with "ValueError: byte string too long" (the default Length is 4, so any 5-byte value hit this); * a value narrower than Length was NUL-padded, silently turning the scan into "these bytes followed by zeros" with no feedback. String (UTF-8) already derived its width from the typed text, and the library itself infers it in resolve_bufflength_for_value — the scanner panel was the only place still asking. Byte Array now follows the same rule: the Length field becomes a read-only readout of the parsed byte count, and build_scan_request lets parse_value size the buffer. Partial matching keeps its own value type (AOB Pattern). This also fixes promoting byte-array results to the cheat table, which read the same spin box and so created entries truncated to 4 bytes. The no-value comparisons (Increased / Changed / …) still read the Length field: they carry no value to measure, and the readout holds the width the previous scan settled on. Closes #79 --- PyMemoryEditor/app/scan_worker.py | 44 ++++++++++-------- PyMemoryEditor/app/scanner_panel.py | 71 +++++++++++++++++------------ tests/app/test_app_scan_request.py | 53 +++++++++++++++++++-- tests/app/test_app_smoke.py | 39 ++++++++++++---- 4 files changed, 148 insertions(+), 59 deletions(-) diff --git a/PyMemoryEditor/app/scan_worker.py b/PyMemoryEditor/app/scan_worker.py index 2d0b303..7163c74 100644 --- a/PyMemoryEditor/app/scan_worker.py +++ b/PyMemoryEditor/app/scan_worker.py @@ -93,10 +93,10 @@ def build_scan_request( This is the pure core of ``ScannerPanel._build_request`` lifted out of the widget so the request-assembly rules (pattern short-circuit, the - str-ignores-length override, range parsing, the no-value scan types) can be - unit-tested without a ``QApplication``. The widget keeps only the bits that - are genuinely UI: reading the fields and showing a ``QMessageBox`` on the - ``ValueError`` raised here. + value-derived width for str / bytes, range parsing, the no-value scan types) + can be unit-tested without a ``QApplication``. The widget keeps only the + bits that are genuinely UI: reading the fields and showing a ``QMessageBox`` + on the ``ValueError`` raised here. :raises ValueError: if a value/pattern fails to parse (message is user-facing — the caller picks the dialog title from ``spec.is_pattern``). @@ -116,19 +116,19 @@ def build_scan_request( writeable_only=writeable_only, ) - # String (UTF-8) ignores the length field: pass None so parse_value derives - # the buffer width from the typed text's UTF-8 byte length. Byte Array still - # honours the user-set override. - length_override = ( - length_spin_value - if spec.accepts_length_override and spec.pytype is not str - else None - ) - # Increased/Decreased/Changed/Unchanged compare current vs previous and need - # no target value — just the value shape (type + length). + # no target value — just the value shape (type + length). With no value to + # measure, the Length field is the only width the variable-width types have + # to go on, so it is honoured here (for str the panel drives it from the — + # now cleared — value text, so fall back to the spec default instead). if scan_type in NO_VALUE_SCAN_TYPES: - length = length_override if length_override is not None else spec.length + length = ( + length_spin_value + if spec.accepts_length_override + and spec.pytype is not str + and length_spin_value is not None + else spec.length + ) return ScanRequest( spec=spec, length=int(length), @@ -137,14 +137,22 @@ def build_scan_request( writeable_only=writeable_only, ) + # Every scan that carries a value sizes its buffer from that value: the + # numeric types have a fixed width, and str / bytes derive theirs in + # parse_value (the text's UTF-8 byte length / the number of hex bytes + # entered). So no length override is passed here. Letting the Length field + # win could only break the scan — a width below the value's raises + # "byte string too long" from the fixed-width ctypes buffer, and a width + # above it NUL-pads the target, silently searching for "the value followed + # by zeros". Partial matching has its own value type (AOB Pattern). value: Any if scan_type in (ScanTypesEnum.VALUE_BETWEEN, ScanTypesEnum.NOT_VALUE_BETWEEN): - lo, lo_len = parse_value(spec, value_text, length_override) - hi, hi_len = parse_value(spec, second_value_text, length_override) + lo, lo_len = parse_value(spec, value_text) + hi, hi_len = parse_value(spec, second_value_text) length = max(lo_len, hi_len) value = (lo, hi) else: - value, length = parse_value(spec, value_text, length_override) + value, length = parse_value(spec, value_text) if not with_value: value = None # Used by callers that only need spec/length/scan_type. diff --git a/PyMemoryEditor/app/scanner_panel.py b/PyMemoryEditor/app/scanner_panel.py index 8316fe2..6866283 100644 --- a/PyMemoryEditor/app/scanner_panel.py +++ b/PyMemoryEditor/app/scanner_panel.py @@ -6,7 +6,7 @@ * primary value (and a second value for "Value Between" / "Not Value Between") * value type * scan type -* explicit byte length for str / bytes +* byte length (fixed per numeric type; derived from the value for str / bytes) * "writable regions only" toggle (passed to PyMemoryEditor as ``writeable_only``) Outputs (signals): @@ -44,7 +44,7 @@ is_next_scan_type, ) from .scan_worker import build_scan_request, ScanRequest -from .value_types import VALUE_TYPES, find_spec +from .value_types import parse_value, VALUE_TYPES, find_spec SCAN_TYPE_CHOICES = ( @@ -119,8 +119,8 @@ def _build_ui(self) -> None: self._value_edit = QLineEdit() self._value_edit.setPlaceholderText("e.g. 100 or 0x64 or Hello") self._value_edit.returnPressed.connect(self._on_value_submitted) - # For String (UTF-8) the length is dictated by the typed text, so keep - # the (disabled) length field in sync as the user types. + # For String (UTF-8) and Byte Array (Hex) the length is dictated by the + # typed value, so keep the (disabled) length field in sync as the user types. self._value_edit.textChanged.connect(self._on_value_text_changed) value_form.addRow("Value:", self._value_edit) @@ -253,7 +253,7 @@ def _on_type_changed(self, label: str) -> None: is_pattern = spec.is_pattern is_regex = spec.is_regex - is_string = spec.pytype is str and not is_pattern + is_sized_by_value = spec.pytype in (str, bytes) and not is_pattern # Pattern modes reuse the "Value" line for the pattern and force the # scan-type combo to EXACT (Bigger/Smaller/Between don't apply). The @@ -261,13 +261,16 @@ def _on_type_changed(self, label: str) -> None: # token count), but a *regex* has no inferable width, so its Length # field stays enabled and supplies search_by_pattern's byte_length. # - # String (UTF-8) also locks the length field: the buffer width is the - # UTF-8 byte length of the typed text (multi-byte aware), so letting the - # user override it would only allow truncating or over-allocating the - # value they entered. The field stays visible as a read-only readout - # kept in sync by _sync_string_length / _on_value_text_changed. + # String (UTF-8) and Byte Array (Hex) lock the length field: the buffer + # width is the size of the value the user entered (the text's UTF-8 byte + # length, multi-byte aware / the number of hex bytes), so letting them + # override it would only allow truncating the value — which the + # fixed-width ctypes buffer rejects outright — or over-allocating it, + # which NUL-pads the target and silently searches for "the value + # followed by zeros". The field stays visible as a read-only readout + # kept in sync by _sync_value_length / _on_value_text_changed. self._length_spin.setEnabled( - (spec.accepts_length_override and not is_pattern and not is_string) + (spec.accepts_length_override and not is_pattern and not is_sized_by_value) or is_regex ) @@ -294,16 +297,12 @@ def _on_type_changed(self, label: str) -> None: self._length_spin.setMaximum(1024) self._length_spin.setValue(1) self._length_spin.setSuffix(" bytes") - elif is_string: - # Length tracks the typed text — raise the ceiling so long strings - # aren't visually clamped, then mirror the current text's byte size. + elif is_sized_by_value: # String (UTF-8) / Byte Array (Hex) + # Length tracks the typed value — raise the ceiling so long strings + # aren't visually clamped, then mirror the current value's byte size. self._length_spin.setMaximum(2_147_483_647) self._length_spin.setSuffix(" bytes") - self._sync_string_length() - elif spec.accepts_length_override: # Byte Array (Hex) - self._length_spin.setMaximum(1024) - self._length_spin.setValue(max(4, self._length_spin.value())) - self._length_spin.setSuffix(" bytes") + self._sync_value_length() else: self._length_spin.setMaximum(1024) self._length_spin.setValue(spec.length) @@ -332,21 +331,37 @@ def _on_type_changed(self, label: str) -> None: self._refresh_buttons() def _on_value_text_changed(self, text: str) -> None: - # Only String (UTF-8) derives its length from the value text; every - # other type owns its length field independently. + # Only the variable-width types (String / Byte Array) derive their + # length from the value text; every other type has a fixed width. spec = find_spec(self._type_combo.currentText()) - if spec is not None and spec.pytype is str and not spec.is_pattern: - self._sync_string_length(text) + if spec is not None and spec.pytype in (str, bytes) and not spec.is_pattern: + self._sync_value_length(text) - def _sync_string_length(self, text: Optional[str] = None) -> None: - """Mirror the UTF-8 byte length of the value text into the length field. + def _sync_value_length(self, text: Optional[str] = None) -> None: + """Mirror the byte size of the value text into the length field. - Matches ``parse_value``'s str rule (byte length, not character count) - so the read-only readout shows exactly the buffer width the scan uses. + Matches ``parse_value``'s rules for the two variable-width types so the + read-only readout shows exactly the buffer width the scan will use: the + UTF-8 byte length for a string (byte length, not character count) and + the number of parsed hex bytes for a byte array. + + A half-typed byte array ("00 1") doesn't parse — the readout keeps its + last valid width rather than flickering while the user types, and the + scan itself re-parses and reports the error if it's still malformed. """ + spec = find_spec(self._type_combo.currentText()) if text is None: text = self._value_edit.text() - self._length_spin.setValue(max(1, len(text.encode("utf-8")))) + + if spec is not None and spec.pytype is bytes: + try: + _, length = parse_value(spec, text) + except ValueError: + return + else: + length = max(1, len(text.encode("utf-8"))) + + self._length_spin.setValue(length) def _on_scan_type_changed(self, index: int) -> None: _, scan_type = SCAN_TYPE_CHOICES[index] diff --git a/tests/app/test_app_scan_request.py b/tests/app/test_app_scan_request.py index 18723ac..035c912 100644 --- a/tests/app/test_app_scan_request.py +++ b/tests/app/test_app_scan_request.py @@ -5,7 +5,7 @@ This is the pure core of ``ScannerPanel._build_request`` — the rules that turn the scanner panel's fields into a ``ScanRequest``: the AOB-pattern short -circuit, the "String ignores the length field / Byte Array honours it" split, +circuit, the value-derived buffer width for String / Byte Array, range parsing, and the no-value (Increased/Decreased/...) scan types. It used to live inside a ``QWidget`` method and could only be exercised by driving the live widget; lifting it out means these rules are now testable without a @@ -17,6 +17,7 @@ pytest.importorskip("PySide6") from PyMemoryEditor import ScanTypesEnum # noqa: E402 +from PyMemoryEditor.util.convert import value_to_bytes # noqa: E402 from PyMemoryEditor.app.scan_types import NextScanType # noqa: E402 from PyMemoryEditor.app.scan_worker import build_scan_request # noqa: E402 from PyMemoryEditor.app.value_types import VALUE_TYPES # noqa: E402 @@ -111,7 +112,10 @@ def test_string_ignores_length_override_and_uses_utf8_byte_length(): assert req.length == 4 -def test_bytes_honours_length_override(): +def test_bytes_ignores_length_override_and_uses_the_parsed_byte_count(): + # The buffer must be exactly as wide as the value entered. A larger spin + # value would NUL-pad the target, turning the scan into "these bytes + # followed by zeros" without telling the user (issue #79). req = build_scan_request( BYTES, ScanTypesEnum.EXACT_VALUE, @@ -119,7 +123,50 @@ def test_bytes_honours_length_override(): length_spin_value=8, ) assert req.value == b"\xaa\xbb" - assert req.length == 8 + assert req.length == 2 + + +def test_bytes_longer_than_the_length_field_is_not_truncated(): + # Regression for issue #79: a value wider than the (default 4) spin value + # used to be squeezed into a 4-byte ctypes buffer, and the scan died with + # "ValueError: byte string too long" instead of just working. + req = build_scan_request( + BYTES, + ScanTypesEnum.EXACT_VALUE, + value_text="00 11 22 AA BB CC", # 6 bytes + length_spin_value=4, + ) + assert req.value == b"\x00\x11\x22\xaa\xbb\xcc" + assert req.length == 6 + # The width is what the backend will encode the target with, so it must + # survive the fixed-width conversion that used to raise. + assert value_to_bytes(bytes, req.length, req.value) == req.value + + +def test_bytes_range_takes_the_wider_endpoint(): + req = build_scan_request( + BYTES, + ScanTypesEnum.VALUE_BETWEEN, + value_text="AA", # 1 byte + second_value_text="AA BB CC", # 3 bytes + length_spin_value=4, + ) + assert req.value == (b"\xaa", b"\xaa\xbb\xcc") + assert req.length == 3 + + +def test_no_value_scan_keeps_the_byte_width_from_the_length_readout(): + # Increased/Changed/... carry no value to measure, so the Length readout + # (which the panel leaves at the previous scan's width) is the only width + # available and must still be honoured. + req = build_scan_request( + BYTES, + NextScanType.CHANGED_VALUE, + value_text="", + length_spin_value=6, + ) + assert req.value is None + assert req.length == 6 def test_no_value_scan_type_drops_value(): diff --git a/tests/app/test_app_smoke.py b/tests/app/test_app_smoke.py index 9e48b1a..95633b3 100644 --- a/tests/app/test_app_smoke.py +++ b/tests/app/test_app_smoke.py @@ -233,11 +233,13 @@ def test_qapplication_starts_under_offscreen(qtbot): @pytest.mark.skipif(not qtbot_available, reason="pytest-qt not installed.") -def test_string_type_locks_length_to_value_text(qtbot): +def test_variable_width_types_lock_length_to_value_text(qtbot): """ - Selecting "String (UTF-8)" disables the length field and drives it from the - UTF-8 byte length of the typed value, so the buffer width always matches the - text the user entered (multi-byte aware). Other types keep an editable length. + Selecting "String (UTF-8)" or "Byte Array (Hex)" disables the length field + and drives it from the size of the typed value, so the buffer width always + matches what the user entered (multi-byte aware for a string, the parsed + byte count for a byte array). Fixed-width types keep the field disabled at + their own size; only the regex type stays editable. """ from PySide6.QtWidgets import QApplication @@ -247,10 +249,6 @@ def test_string_type_locks_length_to_value_text(qtbot): panel = ScannerPanel() qtbot.addWidget(panel) - # Byte Array exposes an editable length field (user-set buffer width). - panel._type_combo.setCurrentText("Byte Array (Hex)") - assert panel._length_spin.isEnabled() - # Switching to String locks the length field... panel._type_combo.setCurrentText("String (UTF-8)") assert not panel._length_spin.isEnabled() @@ -265,9 +263,30 @@ def test_string_type_locks_length_to_value_text(qtbot): assert request.value == "olá" assert request.length == 4 # derived from the text, not the spin override - # Switching back to Byte Array re-enables the field. + # Byte Array behaves the same way (issue #79): the length is the number of + # hex bytes entered, not a separate field the user has to keep in sync. panel._type_combo.setCurrentText("Byte Array (Hex)") - assert panel._length_spin.isEnabled() + assert not panel._length_spin.isEnabled() + + panel._value_edit.setText("00 11 22 AA BB CC") + assert panel._length_spin.value() == 6 + + request = panel._build_request() + assert request is not None + assert request.value == b"\x00\x11\x22\xaa\xbb\xcc" + assert request.length == 6 + + # A half-typed byte pair doesn't parse — the readout holds its last valid + # width instead of flickering while the user types. + panel._value_edit.setText("00 11 22 AA BB C") + assert panel._length_spin.value() == 6 + + # Promoting these results to the cheat table must carry the same width, or + # the entry would show a truncated value. + panel._value_edit.setText("00 11 22 AA BB CC") + spec, length = panel.current_spec_and_length() + assert spec.label == "Byte Array (Hex)" + assert length == 6 panel.close() From 164a64e5e0bd6565d4b2eab62b25a4614262096e Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Thu, 27 Aug 2026 01:52:04 -0300 Subject: [PATCH 02/18] fix(app): keep the length readout usable for next-scan and type switches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two problems in the same neighbourhood, surfaced by review of the byte-array fix. The no-value comparisons (Increased / Changed / …) carry no value to measure, so they read the Length field — but str was excluded and fell back to the spec default of 16. Picking "Changed Value" after a 4-byte "olá" scan refined it by re-reading 16 bytes per address, so the value read back ("olá\0\0…") never equalled the "olá" the first scan had recorded and every address reported as Changed. The readout now holds its last valid width when the value field is cleared (it already did for byte arrays), and the no-value branch honours it for both types. Promoting such a row to the cheat table was hitting the same 1-byte readout and is fixed with it. Switching value type left the readout showing a width computed for the previous type when the value doesn't parse as the new one — 21 bytes of string carried over into Byte Array, for instance. That field is read-only now, so the user had no way to correct it. Selecting a value-sized type seeds the spec default before syncing. Follow-up to #79. --- PyMemoryEditor/app/scan_worker.py | 14 +++++---- PyMemoryEditor/app/scanner_panel.py | 19 ++++++++++--- tests/app/test_app_scan_request.py | 17 +++++++---- tests/app/test_app_smoke.py | 44 +++++++++++++++++++++++++++++ 4 files changed, 78 insertions(+), 16 deletions(-) diff --git a/PyMemoryEditor/app/scan_worker.py b/PyMemoryEditor/app/scan_worker.py index 7163c74..2085f78 100644 --- a/PyMemoryEditor/app/scan_worker.py +++ b/PyMemoryEditor/app/scan_worker.py @@ -118,15 +118,17 @@ def build_scan_request( # Increased/Decreased/Changed/Unchanged compare current vs previous and need # no target value — just the value shape (type + length). With no value to - # measure, the Length field is the only width the variable-width types have - # to go on, so it is honoured here (for str the panel drives it from the — - # now cleared — value text, so fall back to the spec default instead). + # measure, the Length readout is the only width the variable-width types + # have to go on, and it holds the width the previous scan settled on (the + # panel keeps it there when the value field is cleared), so honour it. + # Falling back to the spec default here would refine a 4-byte "olá" scan by + # re-reading 16 bytes per address: the value read back is "olá\0\0…", never + # equal to the "olá" recorded by the first scan, so every address would + # report as Changed. if scan_type in NO_VALUE_SCAN_TYPES: length = ( length_spin_value - if spec.accepts_length_override - and spec.pytype is not str - and length_spin_value is not None + if spec.accepts_length_override and length_spin_value is not None else spec.length ) return ScanRequest( diff --git a/PyMemoryEditor/app/scanner_panel.py b/PyMemoryEditor/app/scanner_panel.py index 6866283..618ca25 100644 --- a/PyMemoryEditor/app/scanner_panel.py +++ b/PyMemoryEditor/app/scanner_panel.py @@ -300,8 +300,13 @@ def _on_type_changed(self, label: str) -> None: elif is_sized_by_value: # String (UTF-8) / Byte Array (Hex) # Length tracks the typed value — raise the ceiling so long strings # aren't visually clamped, then mirror the current value's byte size. + # Seed the spec default first: the readout is read-only now, so a + # value that doesn't parse as the newly picked type (or an empty + # field) must not keep showing a width left over from the previous + # type, which the user would have no way to correct. self._length_spin.setMaximum(2_147_483_647) self._length_spin.setSuffix(" bytes") + self._length_spin.setValue(spec.length) self._sync_value_length() else: self._length_spin.setMaximum(1024) @@ -345,20 +350,26 @@ def _sync_value_length(self, text: Optional[str] = None) -> None: UTF-8 byte length for a string (byte length, not character count) and the number of parsed hex bytes for a byte array. - A half-typed byte array ("00 1") doesn't parse — the readout keeps its - last valid width rather than flickering while the user types, and the - scan itself re-parses and reports the error if it's still malformed. + A value that doesn't size to anything — a half-typed byte array + ("00 1"), or a field the user (or a no-value scan type) just cleared — + leaves the readout on its last valid width rather than flickering or + collapsing to 1. That width is what the "Next Scan" comparisons refine + with, since they carry no value of their own to measure. """ spec = find_spec(self._type_combo.currentText()) + if spec is None: + return if text is None: text = self._value_edit.text() - if spec is not None and spec.pytype is bytes: + if spec.pytype is bytes: try: _, length = parse_value(spec, text) except ValueError: return else: + if not text: + return length = max(1, len(text.encode("utf-8"))) self._length_spin.setValue(length) diff --git a/tests/app/test_app_scan_request.py b/tests/app/test_app_scan_request.py index 035c912..9b293ea 100644 --- a/tests/app/test_app_scan_request.py +++ b/tests/app/test_app_scan_request.py @@ -155,18 +155,23 @@ def test_bytes_range_takes_the_wider_endpoint(): assert req.length == 3 -def test_no_value_scan_keeps_the_byte_width_from_the_length_readout(): +@pytest.mark.parametrize("spec", (BYTES, STR)) +def test_no_value_scan_keeps_the_width_from_the_length_readout(spec): # Increased/Changed/... carry no value to measure, so the Length readout - # (which the panel leaves at the previous scan's width) is the only width - # available and must still be honoured. + # (which the panel leaves at the previous scan's width when the value field + # is cleared) is the only width available and must be honoured for both + # variable-width types. Falling back to the spec default would refine a + # 4-byte scan by re-reading 16 bytes per address, so the value read back + # ("olá\0\0…") never matches the one the first scan recorded and every + # address reports as Changed. req = build_scan_request( - BYTES, + spec, NextScanType.CHANGED_VALUE, value_text="", - length_spin_value=6, + length_spin_value=4, ) assert req.value is None - assert req.length == 6 + assert req.length == 4 def test_no_value_scan_type_drops_value(): diff --git a/tests/app/test_app_smoke.py b/tests/app/test_app_smoke.py index 95633b3..de88bc8 100644 --- a/tests/app/test_app_smoke.py +++ b/tests/app/test_app_smoke.py @@ -288,6 +288,50 @@ def test_variable_width_types_lock_length_to_value_text(qtbot): assert spec.label == "Byte Array (Hex)" assert length == 6 + # Clearing the value (what picking a no-value scan type does) leaves the + # readout on the last width instead of collapsing to 1 — that width is what + # a "Changed Value" next-scan refines with. + panel._value_edit.clear() + assert panel._length_spin.value() == 6 + + panel.close() + + +@pytest.mark.skipif(not qtbot_available, reason="pytest-qt not installed.") +def test_locked_length_readout_is_reseeded_when_the_value_type_changes(qtbot): + """ + The Length readout is read-only for the value-sized types, so a value that + doesn't parse as the newly picked type must fall back to that type's default + width — leaving a stale width from the previous type would be both wrong and + uncorrectable by the user. + """ + from PySide6.QtWidgets import QApplication + + from PyMemoryEditor.app.scanner_panel import ScannerPanel + from PyMemoryEditor.app.value_types import find_spec + + QApplication.instance() or QApplication([]) + panel = ScannerPanel() + qtbot.addWidget(panel) + + bytes_default = find_spec("Byte Array (Hex)").length + str_default = find_spec("String (UTF-8)").length + + # A string value is not valid hex, so switching to Byte Array can't size it. + panel._type_combo.setCurrentText("String (UTF-8)") + panel._value_edit.setText("some long string here") + assert panel._length_spin.value() == 21 + + panel._type_combo.setCurrentText("Byte Array (Hex)") + assert panel._length_spin.value() == bytes_default + assert panel.current_spec_and_length()[1] == bytes_default + + # Same the other way round: an empty field falls back to the spec default + # rather than inheriting whatever the previous type left behind. + panel._value_edit.clear() + panel._type_combo.setCurrentText("String (UTF-8)") + assert panel._length_spin.value() == str_default + panel.close() From 14e79efef33c776bc77e66d14905b9284630aaf0 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Thu, 27 Aug 2026 02:27:44 -0300 Subject: [PATCH 03/18] fix(app): report no length at all until a value-sized scan has a value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening the app lands on Int32, whose Length reads 4. Picking Byte Array (Hex) kept showing that 4 — it was never a width the byte-array scan would use — and the first hex digit typed dropped it to 1, so the one number the field ever showed on its own was wrong. String and Byte Array take their width from the value, and the field is read-only for them, so there is nothing to show and no way for the user to correct whatever we invent. The spin now opens a 0 slot rendered as "— (set by the value)" for that state, and fills in a real number as soon as the value sizes to one. The two consumers that could see the 0 (the no-value next-scan width and the promote-to-cheat-table length) fall back to the spec default. Selecting any other type clears the special-value text and restores the minimum of 1, so a 1-byte Int8 shows "1 bytes" rather than landing on the empty-slot rendering. Follow-up to #79. --- PyMemoryEditor/app/scan_worker.py | 5 +++- PyMemoryEditor/app/scanner_panel.py | 34 ++++++++++++++++++---- tests/app/test_app_smoke.py | 44 ++++++++++++++++++----------- 3 files changed, 59 insertions(+), 24 deletions(-) diff --git a/PyMemoryEditor/app/scan_worker.py b/PyMemoryEditor/app/scan_worker.py index 2085f78..2b5c77c 100644 --- a/PyMemoryEditor/app/scan_worker.py +++ b/PyMemoryEditor/app/scan_worker.py @@ -125,10 +125,13 @@ def build_scan_request( # re-reading 16 bytes per address: the value read back is "olá\0\0…", never # equal to the "olá" recorded by the first scan, so every address would # report as Changed. + # A falsy length_spin_value means the panel has no width to offer (the field + # reads 0 for a value-sized type before any value was entered), so the spec + # default stands in. if scan_type in NO_VALUE_SCAN_TYPES: length = ( length_spin_value - if spec.accepts_length_override and length_spin_value is not None + if spec.accepts_length_override and length_spin_value else spec.length ) return ScanRequest( diff --git a/PyMemoryEditor/app/scanner_panel.py b/PyMemoryEditor/app/scanner_panel.py index 618ca25..c8e047c 100644 --- a/PyMemoryEditor/app/scanner_panel.py +++ b/PyMemoryEditor/app/scanner_panel.py @@ -47,6 +47,12 @@ from .value_types import parse_value, VALUE_TYPES, find_spec +# Shown in the (read-only) Length field of String / Byte Array before a value +# has been entered: those types take their width from the value itself, so +# there is genuinely no number to report yet. +EMPTY_LENGTH_TEXT = "— (set by the value)" + + SCAN_TYPE_CHOICES = ( ("Exact Value", ScanTypesEnum.EXACT_VALUE), ("Not Exact Value", ScanTypesEnum.NOT_EXACT_VALUE), @@ -285,6 +291,13 @@ def _on_type_changed(self, label: str) -> None: else: self._value_edit.setPlaceholderText("e.g. 100 or 0x64 or Hello") + # Every type but the value-sized pair owns a real number here, so clear + # the "no width yet" slot the previous type may have opened (0 would + # otherwise render as EMPTY_LENGTH_TEXT for e.g. "1 Byte (Int8)"). + if not is_sized_by_value: + self._length_spin.setSpecialValueText("") + self._length_spin.setMinimum(1) + if is_regex: # Length = the regex's max match width in bytes (byte_length); it # drives the chunk overlap so a match straddling a chunk boundary is @@ -300,13 +313,18 @@ def _on_type_changed(self, label: str) -> None: elif is_sized_by_value: # String (UTF-8) / Byte Array (Hex) # Length tracks the typed value — raise the ceiling so long strings # aren't visually clamped, then mirror the current value's byte size. - # Seed the spec default first: the readout is read-only now, so a - # value that doesn't parse as the newly picked type (or an empty - # field) must not keep showing a width left over from the previous - # type, which the user would have no way to correct. + # + # Until a value has been entered there is no width to report, and + # the readout is read-only, so the user can't correct a number we + # invent. Open a 0 slot rendered as EMPTY_LENGTH_TEXT for that + # state rather than seeding the spec default (which would show + # "4 bytes" for an empty byte array and then jump to "1 byte" on + # the first hex digit) or keeping a width the previous type wrote. + self._length_spin.setMinimum(0) + self._length_spin.setSpecialValueText(EMPTY_LENGTH_TEXT) self._length_spin.setMaximum(2_147_483_647) self._length_spin.setSuffix(" bytes") - self._length_spin.setValue(spec.length) + self._length_spin.setValue(0) self._sync_value_length() else: self._length_spin.setMaximum(1024) @@ -461,4 +479,8 @@ def current_spec_and_length(self): length = ( self._length_spin.value() if spec.accepts_length_override else spec.length ) - return spec, int(length) + # A value-sized type with no value entered yet reads 0 (the + # EMPTY_LENGTH_TEXT slot); a cheat entry can't have a zero-width buffer, + # so fall back to the spec default. In practice promoting requires scan + # results, which can only exist once a value set a real width. + return spec, int(length) or spec.length diff --git a/tests/app/test_app_smoke.py b/tests/app/test_app_smoke.py index de88bc8..df8dd66 100644 --- a/tests/app/test_app_smoke.py +++ b/tests/app/test_app_smoke.py @@ -298,39 +298,49 @@ def test_variable_width_types_lock_length_to_value_text(qtbot): @pytest.mark.skipif(not qtbot_available, reason="pytest-qt not installed.") -def test_locked_length_readout_is_reseeded_when_the_value_type_changes(qtbot): +def test_length_readout_reports_no_width_until_a_value_is_entered(qtbot): """ - The Length readout is read-only for the value-sized types, so a value that - doesn't parse as the newly picked type must fall back to that type's default - width — leaving a stale width from the previous type would be both wrong and - uncorrectable by the user. + A value-sized type has no width to report before a value exists, and the + readout is read-only, so it must say so rather than show a number the scan + would never use — picking Byte Array on a fresh panel used to inherit the + Int32 "4 bytes" and then drop to "1 byte" on the first hex digit. """ from PySide6.QtWidgets import QApplication - from PyMemoryEditor.app.scanner_panel import ScannerPanel + from PyMemoryEditor.app.scanner_panel import EMPTY_LENGTH_TEXT, ScannerPanel from PyMemoryEditor.app.value_types import find_spec QApplication.instance() or QApplication([]) panel = ScannerPanel() qtbot.addWidget(panel) - bytes_default = find_spec("Byte Array (Hex)").length - str_default = find_spec("String (UTF-8)").length + # Fresh panel: Int32 shows its own fixed width. + assert panel._length_spin.value() == 4 + + # Byte Array with nothing typed reports no width at all — not the 4 it + # inherited, and not a default that would jump to 1 on the first digit. + panel._type_combo.setCurrentText("Byte Array (Hex)") + assert panel._length_spin.value() == 0 + assert panel._length_spin.text() == EMPTY_LENGTH_TEXT + panel._value_edit.setText("AA") + assert panel._length_spin.value() == 1 - # A string value is not valid hex, so switching to Byte Array can't size it. + # A string value is not valid hex, so switching back to Byte Array can't + # size it: no stale width carries over from String. panel._type_combo.setCurrentText("String (UTF-8)") panel._value_edit.setText("some long string here") assert panel._length_spin.value() == 21 panel._type_combo.setCurrentText("Byte Array (Hex)") - assert panel._length_spin.value() == bytes_default - assert panel.current_spec_and_length()[1] == bytes_default - - # Same the other way round: an empty field falls back to the spec default - # rather than inheriting whatever the previous type left behind. - panel._value_edit.clear() - panel._type_combo.setCurrentText("String (UTF-8)") - assert panel._length_spin.value() == str_default + assert panel._length_spin.value() == 0 + # Promoting can't produce a zero-width cheat entry even in that state. + assert panel.current_spec_and_length()[1] == find_spec("Byte Array (Hex)").length + + # A fixed-width type picked afterwards must show its number, never the + # empty-slot text (a 1-byte Int8 sits at what was the special value). + panel._type_combo.setCurrentText("1 Byte (Int8)") + assert panel._length_spin.value() == 1 + assert panel._length_spin.text() != EMPTY_LENGTH_TEXT panel.close() From ba64e899b4d9d2f5cd8db4584ab1b28aef5b4c7b Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Thu, 27 Aug 2026 02:56:37 -0300 Subject: [PATCH 04/18] fix(app): refine no-value scans at the width their baseline was recorded with MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Increased / Decreased / Changed / Unchanged compare against the values the previous scan recorded, so they have to re-read at that scan's width. They were reading the Length field instead, which tracks the value currently in the Value box — and the user can edit that between scans without rescanning. First Scan for the byte array `00 11`, type `00 11 22 33`, pick "Changed Value", Next Scan: every address is re-read 4 bytes wide, compared against a 2-byte baseline, and reported as Changed. "Update Values" was worse, writing the mismatched reads back over the baseline for every later comparison. The panel now records the width of each scan it emits and passes it as previous_scan_length; it's dropped along with the results it describes. The Length readout no longer has to double as that memory, so it goes back to being exactly what the current value sizes to — which also fixes it claiming "4 bytes" for a String whose value box had been cleared while the request was built at length 1. Two neighbouring bugs on the same paths: * promoting an AOB hit to the cheat table created a zero-width entry (no Length field, and the IDA spec's length is 0), re-read as empty on every poll tick. It now measures the pattern — one token, one byte; * an empty String value parsed to a 1-byte NUL buffer, so scanning it matched every zeroed byte in the target. Byte Array already rejected its own empty input; both now do. Follow-up to #79. --- PyMemoryEditor/app/scan_worker.py | 30 ++++++----- PyMemoryEditor/app/scanner_panel.py | 57 +++++++++++++++----- PyMemoryEditor/app/value_types.py | 15 +++++- tests/app/test_app_scan_request.py | 38 ++++++++++++-- tests/app/test_app_smoke.py | 81 ++++++++++++++++++++++++++--- 5 files changed, 179 insertions(+), 42 deletions(-) diff --git a/PyMemoryEditor/app/scan_worker.py b/PyMemoryEditor/app/scan_worker.py index 2b5c77c..4a8cc00 100644 --- a/PyMemoryEditor/app/scan_worker.py +++ b/PyMemoryEditor/app/scan_worker.py @@ -85,6 +85,7 @@ def build_scan_request( value_text: str, second_value_text: str = "", length_spin_value: Optional[int] = None, + previous_scan_length: Optional[int] = None, writeable_only: bool = False, with_value: bool = True, ) -> ScanRequest: @@ -98,6 +99,12 @@ def build_scan_request( bits that are genuinely UI: reading the fields and showing a ``QMessageBox`` on the ``ValueError`` raised here. + :param length_spin_value: the Length field. Only the regex type reads it + (as ``byte_length``) — every other type derives its width from the spec + or from the value itself. + :param previous_scan_length: the width the scan that produced the current + results used. Only the no-value comparisons read it, and only for the + variable-width types, whose baseline is meaningless at another width. :raises ValueError: if a value/pattern fails to parse (message is user-facing — the caller picks the dialog title from ``spec.is_pattern``). """ @@ -116,22 +123,17 @@ def build_scan_request( writeable_only=writeable_only, ) - # Increased/Decreased/Changed/Unchanged compare current vs previous and need - # no target value — just the value shape (type + length). With no value to - # measure, the Length readout is the only width the variable-width types - # have to go on, and it holds the width the previous scan settled on (the - # panel keeps it there when the value field is cleared), so honour it. - # Falling back to the spec default here would refine a 4-byte "olá" scan by - # re-reading 16 bytes per address: the value read back is "olá\0\0…", never - # equal to the "olá" recorded by the first scan, so every address would - # report as Changed. - # A falsy length_spin_value means the panel has no width to offer (the field - # reads 0 for a value-sized type before any value was entered), so the spec - # default stands in. + # Increased/Decreased/Changed/Unchanged compare the value read now against + # the one the *previous* scan recorded, so they need no target value — but + # they must re-read at the width that baseline was recorded with. Reading + # 16 bytes where the first scan recorded 4 yields "olá\0\0…" against "olá", + # which never compares equal, so every address would report as Changed. + # ``previous_scan_length`` carries that width for the variable-width types; + # the fixed-width types own theirs and ignore it. if scan_type in NO_VALUE_SCAN_TYPES: length = ( - length_spin_value - if spec.accepts_length_override and length_spin_value + previous_scan_length + if spec.accepts_length_override and previous_scan_length else spec.length ) return ScanRequest( diff --git a/PyMemoryEditor/app/scanner_panel.py b/PyMemoryEditor/app/scanner_panel.py index c8e047c..f3edf2b 100644 --- a/PyMemoryEditor/app/scanner_panel.py +++ b/PyMemoryEditor/app/scanner_panel.py @@ -85,6 +85,12 @@ def __init__(self, parent=None): self._has_results = False self._busy = False self._initial_focus_done = False + # Width the scan that produced the current results ran at. The no-value + # comparisons (Increased / Changed / …) must re-read at exactly this + # width or they compare against a baseline recorded at another one; the + # Length readout can't stand in, since it tracks whatever value is in + # the field right now, which the user is free to edit between scans. + self._last_scan_length: Optional[int] = None self._build_ui() self._refresh_buttons() @@ -223,6 +229,8 @@ def _build_ui(self) -> None: def set_has_results(self, has_results: bool) -> None: self._has_results = has_results + if not has_results: + self._last_scan_length = None self._refresh_buttons() def set_busy(self, busy: bool) -> None: @@ -368,11 +376,12 @@ def _sync_value_length(self, text: Optional[str] = None) -> None: UTF-8 byte length for a string (byte length, not character count) and the number of parsed hex bytes for a byte array. - A value that doesn't size to anything — a half-typed byte array - ("00 1"), or a field the user (or a no-value scan type) just cleared — - leaves the readout on its last valid width rather than flickering or - collapsing to 1. That width is what the "Next Scan" comparisons refine - with, since they carry no value of their own to measure. + The readout is exactly what the current value sizes to, and nothing + else: an empty field, or a half-typed byte array ("00 1"), reports no + width at all (EMPTY_LENGTH_TEXT) rather than a number no scan would + use. The "Next Scan" comparisons that carry no value of their own don't + read this field — they refine at ``_last_scan_length``, the width the + scan holding the current results actually ran at. """ spec = find_spec(self._type_combo.currentText()) if spec is None: @@ -380,15 +389,10 @@ def _sync_value_length(self, text: Optional[str] = None) -> None: if text is None: text = self._value_edit.text() - if spec.pytype is bytes: - try: - _, length = parse_value(spec, text) - except ValueError: - return - else: - if not text: - return - length = max(1, len(text.encode("utf-8"))) + try: + _, length = parse_value(spec, text) + except ValueError: + length = 0 self._length_spin.setValue(length) @@ -437,6 +441,7 @@ def _build_request(self, *, with_value: bool = True) -> Optional[ScanRequest]: value_text=self._value_edit.text(), second_value_text=self._second_value_edit.text(), length_spin_value=self._length_spin.value(), + previous_scan_length=self._last_scan_length, writeable_only=self._writable_check.isChecked(), with_value=with_value, ) @@ -459,16 +464,21 @@ def _on_first_scan(self) -> None: return request = self._build_request() if request is not None: + self._last_scan_length = request.length self.first_scan_requested.emit(request) def _on_next_scan(self) -> None: request = self._build_request() if request is not None: + # The refine rewrites every kept value at this width, so it becomes + # the baseline the next no-value comparison has to match. + self._last_scan_length = request.length self.next_scan_requested.emit(request) def _on_update_values(self) -> None: request = self._build_request() if request is not None: + self._last_scan_length = request.length self.update_values_requested.emit(request) def current_spec_and_length(self): @@ -476,6 +486,13 @@ def current_spec_and_length(self): spec = find_spec(self._type_combo.currentText()) if spec is None: spec = VALUE_TYPES[0] + # An IDA pattern has no Length field and a spec length of 0 (the scanner + # derives the width from the pattern), so a promoted AOB hit would get a + # zero-byte buffer that the cheat table then re-reads as empty on every + # poll tick. Measure the pattern instead — one token is one byte. + if spec.is_pattern and not spec.is_regex: + return spec, self._pattern_byte_length() + length = ( self._length_spin.value() if spec.accepts_length_override else spec.length ) @@ -484,3 +501,15 @@ def current_spec_and_length(self): # so fall back to the spec default. In practice promoting requires scan # results, which can only exist once a value set a real width. return spec, int(length) or spec.length + + def _pattern_byte_length(self) -> int: + """Width of one match of the AOB pattern currently in the Value field.""" + from PyMemoryEditor.util.pattern import compile_pattern + + try: + return max(1, compile_pattern(self._value_edit.text().strip())[1]) + except ValueError: + # The results being promoted came from a pattern that compiled, so + # this only happens if the field was edited afterwards. One byte is + # a harmless entry the user can widen from the cheat table. + return 1 diff --git a/PyMemoryEditor/app/value_types.py b/PyMemoryEditor/app/value_types.py index 0e3d2f4..6c4dc15 100644 --- a/PyMemoryEditor/app/value_types.py +++ b/PyMemoryEditor/app/value_types.py @@ -86,6 +86,19 @@ def _parse_bytes(text: str) -> bytes: raise ValueError(f"Invalid byte array: {exc}") +def _parse_str(text: str) -> str: + """Return the value text verbatim, rejecting an empty one. + + An empty string encodes to a 1-byte NUL buffer, so scanning for it matches + every zeroed byte in the target — a nonsense result the user almost + certainly didn't ask for. ``_parse_bytes`` already rejects its own empty + input; this keeps the two variable-width types consistent. + """ + if not text: + raise ValueError("Empty value. Type the text to search for.") + return text + + def _parse_pattern(text: str) -> str: """Validate an IDA-style AOB pattern and return it verbatim. @@ -231,7 +244,7 @@ def _fmt_int(value): "String (UTF-8)", str, 16, - lambda s: s, + _parse_str, lambda v: "" if v is None else str(v), accepts_length_override=True, ), diff --git a/tests/app/test_app_scan_request.py b/tests/app/test_app_scan_request.py index 9b293ea..f85c5b0 100644 --- a/tests/app/test_app_scan_request.py +++ b/tests/app/test_app_scan_request.py @@ -156,10 +156,9 @@ def test_bytes_range_takes_the_wider_endpoint(): @pytest.mark.parametrize("spec", (BYTES, STR)) -def test_no_value_scan_keeps_the_width_from_the_length_readout(spec): - # Increased/Changed/... carry no value to measure, so the Length readout - # (which the panel leaves at the previous scan's width when the value field - # is cleared) is the only width available and must be honoured for both +def test_no_value_scan_refines_at_the_previous_scan_width(spec): + # Increased/Changed/... compare against the baseline the previous scan + # recorded, so they must re-read at that scan's width for both # variable-width types. Falling back to the spec default would refine a # 4-byte scan by re-reading 16 bytes per address, so the value read back # ("olá\0\0…") never matches the one the first scan recorded and every @@ -168,12 +167,32 @@ def test_no_value_scan_keeps_the_width_from_the_length_readout(spec): spec, NextScanType.CHANGED_VALUE, value_text="", - length_spin_value=4, + previous_scan_length=4, ) assert req.value is None assert req.length == 4 +@pytest.mark.parametrize("spec", (BYTES, STR)) +def test_no_value_scan_ignores_the_length_field(spec): + # The Length field tracks whatever value is typed right now, which the user + # is free to edit between scans — only the previous scan's width describes + # the baseline being compared against. + req = build_scan_request( + spec, + NextScanType.CHANGED_VALUE, + value_text="", + length_spin_value=99, + previous_scan_length=4, + ) + assert req.length == 4 + + +def test_no_value_scan_falls_back_to_the_spec_width_without_a_previous_scan(): + req = build_scan_request(BYTES, NextScanType.CHANGED_VALUE, value_text="") + assert req.length == BYTES.length + + def test_no_value_scan_type_drops_value(): req = build_scan_request( INT4, @@ -201,6 +220,15 @@ def test_invalid_value_raises_valueerror(): build_scan_request(INT4, ScanTypesEnum.EXACT_VALUE, value_text="not-an-int") +@pytest.mark.parametrize("spec", (BYTES, STR)) +def test_empty_value_raises_valueerror(spec): + # An empty string used to parse as a 1-byte NUL buffer, so the scan matched + # every zeroed byte in the target. Byte Array already rejected its own empty + # input; both variable-width types now do. + with pytest.raises(ValueError): + build_scan_request(spec, ScanTypesEnum.EXACT_VALUE, value_text="") + + def test_invalid_pattern_raises_valueerror(): with pytest.raises(ValueError): build_scan_request(AOB, ScanTypesEnum.EXACT_VALUE, value_text="") diff --git a/tests/app/test_app_smoke.py b/tests/app/test_app_smoke.py index df8dd66..92edeb0 100644 --- a/tests/app/test_app_smoke.py +++ b/tests/app/test_app_smoke.py @@ -276,10 +276,10 @@ def test_variable_width_types_lock_length_to_value_text(qtbot): assert request.value == b"\x00\x11\x22\xaa\xbb\xcc" assert request.length == 6 - # A half-typed byte pair doesn't parse — the readout holds its last valid - # width instead of flickering while the user types. + # A half-typed byte pair doesn't size to anything, so the readout reports no + # width rather than a number no scan would use. panel._value_edit.setText("00 11 22 AA BB C") - assert panel._length_spin.value() == 6 + assert panel._length_spin.value() == 0 # Promoting these results to the cheat table must carry the same width, or # the entry would show a truncated value. @@ -288,11 +288,76 @@ def test_variable_width_types_lock_length_to_value_text(qtbot): assert spec.label == "Byte Array (Hex)" assert length == 6 - # Clearing the value (what picking a no-value scan type does) leaves the - # readout on the last width instead of collapsing to 1 — that width is what - # a "Changed Value" next-scan refines with. - panel._value_edit.clear() - assert panel._length_spin.value() == 6 + panel.close() + + +@pytest.mark.skipif(not qtbot_available, reason="pytest-qt not installed.") +def test_no_value_next_scan_refines_at_the_scanned_width(qtbot): + """ + Increased / Changed / … compare against the baseline the previous scan + recorded, so they must re-read at *that* scan's width. The Length readout + can't stand in: it tracks the value currently in the field, which the user + is free to edit after the first scan. + """ + from PySide6.QtWidgets import QApplication + + from PyMemoryEditor.app.scan_types import NextScanType + from PyMemoryEditor.app.scanner_panel import SCAN_TYPE_CHOICES, ScannerPanel + + QApplication.instance() or QApplication([]) + panel = ScannerPanel() + qtbot.addWidget(panel) + + panel._type_combo.setCurrentText("Byte Array (Hex)") + panel._value_edit.setText("00 11") + panel._on_first_scan() # records a 2-byte baseline + panel.set_has_results(True) + + # The user edits the value without rescanning: the readout follows the new + # value, but the results on screen are still the 2-byte ones. + panel._value_edit.setText("00 11 22 33") + assert panel._length_spin.value() == 4 + + changed = next( + i + for i, (_, t) in enumerate(SCAN_TYPE_CHOICES) + if t is NextScanType.CHANGED_VALUE + ) + panel._scan_combo.setCurrentIndex(changed) + + request = panel._build_request() + assert request is not None + assert request.value is None + assert request.length == 2 # the scanned width, not the readout's 4 + + # Dropping the results drops the baseline with them. + panel.set_has_results(False) + assert panel._last_scan_length is None + + panel.close() + + +@pytest.mark.skipif(not qtbot_available, reason="pytest-qt not installed.") +def test_promoting_an_aob_hit_uses_the_pattern_width(qtbot): + """ + An IDA pattern has no Length field and a spec length of 0, so a promoted hit + would land in the cheat table as a zero-byte entry that reads back empty on + every poll tick. The width is the pattern's own — one token, one byte. + """ + from PySide6.QtWidgets import QApplication + + from PyMemoryEditor.app.scanner_panel import ScannerPanel + + QApplication.instance() or QApplication([]) + panel = ScannerPanel() + qtbot.addWidget(panel) + + panel._type_combo.setCurrentText("AOB Pattern (IDA)") + panel._value_edit.setText("48 8B ? ? 00") + + spec, length = panel.current_spec_and_length() + assert spec.label == "AOB Pattern (IDA)" + assert length == 5 # five tokens, wildcards included panel.close() From 2e2098109eb3fca18b670116bbc1b426d451a30f Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Thu, 27 Aug 2026 14:09:08 -0300 Subject: [PATCH 05/18] fix(app): size every operation on existing results by the scan that made them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third round of the same mistake, in the paths the previous fix didn't reach. All four take the width from the Length readout, which follows the Value box — a no-value scan type clears that box outright, and the user can retype it between scans without rescanning: * promoting to the cheat table fell through to the spec default: a 4-byte "olá" scan promoted at 16, so the entry pulled 12 bytes of neighbouring memory into the cell on every poll tick; * "Increased/Decreased value BY" sit outside NO_VALUE_SCAN_TYPES, so they missed the previous fix and sized the re-read from the *delta* text — a 4-byte baseline re-read 1 byte at a time, dropping every address, and then written back as the new baseline width; * "Update Values" is a read-only refresh, but re-read at whatever was in the Value box and moved the baseline to match, overwriting every stored value with a truncated read; * the readout ignored the range upper bound while the scan sized with max(lo, hi), so a "AA".."AA BB CC" range reported 1 byte and ran at 3. All four now use the width the results were scanned at. _sync_value_length reads both bounds and no longer needs its callers to check the type. Also: re-picking "AOB Pattern (IDA)" on a promoted hit reset the entry to a zero-width buffer (the spec's own length is 0), undoing the pattern width from the other side; and the empty-value message is neutral now that it surfaces in the cheat table's write dialogs too. Follow-up to #79. --- PyMemoryEditor/app/cheat_table.py | 5 +- PyMemoryEditor/app/scan_worker.py | 14 ++++- PyMemoryEditor/app/scanner_panel.py | 88 ++++++++++++++++++++--------- PyMemoryEditor/app/value_types.py | 14 +++-- tests/app/test_app_scan_request.py | 26 +++++++++ tests/app/test_app_smoke.py | 83 +++++++++++++++++++++++++++ 6 files changed, 197 insertions(+), 33 deletions(-) diff --git a/PyMemoryEditor/app/cheat_table.py b/PyMemoryEditor/app/cheat_table.py index c2d16f9..faccfd2 100644 --- a/PyMemoryEditor/app/cheat_table.py +++ b/PyMemoryEditor/app/cheat_table.py @@ -668,7 +668,10 @@ def _change_type(self, row: int) -> None: self._entries[row].spec_label = chosen spec = find_spec(chosen) or VALUE_TYPES[0] if not spec.accepts_length_override: - self._entries[row].length = spec.length + # The IDA pattern spec has no width of its own (the scanner derives + # it from the pattern), so keep whatever the entry was promoted + # with rather than collapsing it to a zero-byte buffer. + self._entries[row].length = spec.length or self._entries[row].length self._rebuild() def _change_length(self, row: int) -> None: diff --git a/PyMemoryEditor/app/scan_worker.py b/PyMemoryEditor/app/scan_worker.py index 4a8cc00..a7e06f5 100644 --- a/PyMemoryEditor/app/scan_worker.py +++ b/PyMemoryEditor/app/scan_worker.py @@ -22,7 +22,12 @@ from PyMemoryEditor import AbstractProcess, MemoryRegion, ScanTypesEnum -from .scan_types import NextScanType, NO_VALUE_SCAN_TYPES, ScanType +from .scan_types import ( + DELTA_SCAN_TYPES, + NextScanType, + NO_VALUE_SCAN_TYPES, + ScanType, +) from .value_types import parse_value, ValueTypeSpec @@ -161,6 +166,13 @@ def build_scan_request( else: value, length = parse_value(spec, value_text) + # "Increased/Decreased value BY" carry a value — the delta — but still + # compare against the previous scan's baseline, so like the no-value + # comparisons they must re-read at that scan's width. Sizing the read from + # the delta text instead would re-read a 4-byte baseline 1 byte at a time. + if scan_type in DELTA_SCAN_TYPES and spec.accepts_length_override: + length = previous_scan_length or length + if not with_value: value = None # Used by callers that only need spec/length/scan_type. diff --git a/PyMemoryEditor/app/scanner_panel.py b/PyMemoryEditor/app/scanner_panel.py index f3edf2b..4978ca0 100644 --- a/PyMemoryEditor/app/scanner_panel.py +++ b/PyMemoryEditor/app/scanner_panel.py @@ -52,6 +52,11 @@ # there is genuinely no number to report yet. EMPTY_LENGTH_TEXT = "— (set by the value)" +# The two scan types that take a second value; their width is max(lo, hi). +RANGE_SCAN_TYPES = frozenset( + (ScanTypesEnum.VALUE_BETWEEN, ScanTypesEnum.NOT_VALUE_BETWEEN) +) + SCAN_TYPE_CHOICES = ( ("Exact Value", ScanTypesEnum.EXACT_VALUE), @@ -139,6 +144,9 @@ def _build_ui(self) -> None: self._second_value_edit = QLineEdit() self._second_value_edit.setPlaceholderText("Upper bound (for ranges only)") self._second_value_edit.returnPressed.connect(self._on_value_submitted) + # A range scan sizes with max(lo, hi), so the upper bound moves the + # readout just as the primary value does. + self._second_value_edit.textChanged.connect(self._on_value_text_changed) self._second_value_label = QLabel("Up to:") value_form.addRow(self._second_value_label, self._second_value_edit) self._second_value_edit.hide() @@ -362,11 +370,8 @@ def _on_type_changed(self, label: str) -> None: self._refresh_buttons() def _on_value_text_changed(self, text: str) -> None: - # Only the variable-width types (String / Byte Array) derive their - # length from the value text; every other type has a fixed width. - spec = find_spec(self._type_combo.currentText()) - if spec is not None and spec.pytype in (str, bytes) and not spec.is_pattern: - self._sync_value_length(text) + # _sync_value_length ignores the types that own their length field. + self._sync_value_length(text) def _sync_value_length(self, text: Optional[str] = None) -> None: """Mirror the byte size of the value text into the length field. @@ -379,31 +384,48 @@ def _sync_value_length(self, text: Optional[str] = None) -> None: The readout is exactly what the current value sizes to, and nothing else: an empty field, or a half-typed byte array ("00 1"), reports no width at all (EMPTY_LENGTH_TEXT) rather than a number no scan would - use. The "Next Scan" comparisons that carry no value of their own don't - read this field — they refine at ``_last_scan_length``, the width the - scan holding the current results actually ran at. + use. A range scan sizes with ``max(lo, hi)``, so both bounds count. + The "Next Scan" comparisons that carry no value of their own don't read + this field — they refine at ``_last_scan_length``, the width the scan + holding the current results actually ran at. + + ``text`` is accepted (and ignored) so the method can sit directly on a + ``textChanged`` signal; the width always comes from reading the fields, + since either of the two can be the one that sets it. """ + del text # Both fields are read below; see the docstring. spec = find_spec(self._type_combo.currentText()) - if spec is None: + # Only the value-sized types have a width to mirror; every other type + # owns the field (a fixed width, or the regex's editable match width) + # and must not have it overwritten from here. + if spec is None or spec.is_pattern or spec.pytype not in (str, bytes): return - if text is None: - text = self._value_edit.text() - try: - _, length = parse_value(spec, text) - except ValueError: - length = 0 + texts = [self._value_edit.text()] + # Read the scan type rather than the widget's visibility: a child of a + # panel that hasn't been shown yet reports isVisible() False even after + # setVisible(True), which would silently drop the upper bound. + _, scan_type = SCAN_TYPE_CHOICES[self._scan_combo.currentIndex()] + if scan_type in RANGE_SCAN_TYPES: + texts.append(self._second_value_edit.text()) + + length = 0 + for candidate in texts: + try: + _, candidate_length = parse_value(spec, candidate) + except ValueError: + continue + length = max(length, candidate_length) self._length_spin.setValue(length) def _on_scan_type_changed(self, index: int) -> None: _, scan_type = SCAN_TYPE_CHOICES[index] - ranged = scan_type in ( - ScanTypesEnum.VALUE_BETWEEN, - ScanTypesEnum.NOT_VALUE_BETWEEN, - ) + ranged = scan_type in RANGE_SCAN_TYPES self._second_value_edit.setVisible(ranged) self._second_value_label.setVisible(ranged) + # Entering or leaving a range changes which fields size the scan. + self._sync_value_length() # In pattern mode the Value field holds the AOB pattern and the # scan-type combo is forced to EXACT, so leave its value field alone. @@ -477,9 +499,16 @@ def _on_next_scan(self) -> None: def _on_update_values(self) -> None: request = self._build_request() - if request is not None: - self._last_scan_length = request.length - self.update_values_requested.emit(request) + if request is None: + return + # A read-only refresh of the results already on screen: it must re-read + # them at the width they were scanned at, and must not move that + # baseline — the Value box may hold a candidate for the *next* scan, + # and sizing the re-read from it would overwrite every stored value + # with a truncated read. + if request.spec.accepts_length_override and self._last_scan_length: + request.length = self._last_scan_length + self.update_values_requested.emit(request) def current_spec_and_length(self): """Return the active (spec, length) pair for the Promote-to-Cheat-Table path.""" @@ -493,13 +522,20 @@ def current_spec_and_length(self): if spec.is_pattern and not spec.is_regex: return spec, self._pattern_byte_length() + # The rows being promoted were read at the width their scan ran at, so + # that is the width the cheat entry has to keep. The Length readout + # can't stand in: it follows the Value box, which a no-value scan type + # clears outright (a 4-byte "olá" scan would promote at the spec's 16, + # and the entry would read 12 bytes of neighbouring memory into the + # cell on every poll tick). + if spec.accepts_length_override and self._last_scan_length: + return spec, self._last_scan_length + length = ( self._length_spin.value() if spec.accepts_length_override else spec.length ) - # A value-sized type with no value entered yet reads 0 (the - # EMPTY_LENGTH_TEXT slot); a cheat entry can't have a zero-width buffer, - # so fall back to the spec default. In practice promoting requires scan - # results, which can only exist once a value set a real width. + # No scan has run yet and no value is entered (readout 0): a cheat entry + # can't have a zero-width buffer, so the spec default stands in. return spec, int(length) or spec.length def _pattern_byte_length(self) -> int: diff --git a/PyMemoryEditor/app/value_types.py b/PyMemoryEditor/app/value_types.py index 6c4dc15..30b02ce 100644 --- a/PyMemoryEditor/app/value_types.py +++ b/PyMemoryEditor/app/value_types.py @@ -89,13 +89,17 @@ def _parse_bytes(text: str) -> bytes: def _parse_str(text: str) -> str: """Return the value text verbatim, rejecting an empty one. - An empty string encodes to a 1-byte NUL buffer, so scanning for it matches - every zeroed byte in the target — a nonsense result the user almost - certainly didn't ask for. ``_parse_bytes`` already rejects its own empty - input; this keeps the two variable-width types consistent. + An empty string sizes to a 1-byte NUL buffer, so *scanning* for it matches + every zeroed byte in the target, and *writing* it is a no-op (``prepare_write`` + truncates to the value and never pads). Neither is what the user meant. + ``_parse_bytes`` already rejects its own empty input; this keeps the two + variable-width types consistent. + + The wording stays neutral because this runs on the cheat table's write + paths too, not only on a scan. """ if not text: - raise ValueError("Empty value. Type the text to search for.") + raise ValueError("Empty value.") return text diff --git a/tests/app/test_app_scan_request.py b/tests/app/test_app_scan_request.py index f85c5b0..c873ba1 100644 --- a/tests/app/test_app_scan_request.py +++ b/tests/app/test_app_scan_request.py @@ -188,6 +188,32 @@ def test_no_value_scan_ignores_the_length_field(spec): assert req.length == 4 +@pytest.mark.parametrize("spec", (BYTES, STR)) +def test_delta_scan_refines_at_the_previous_scan_width(spec): + # "Increased/Decreased value BY" carry a delta but still compare against the + # previous scan's baseline, so the re-read width is that scan's, not the + # delta text's — sizing from the delta would re-read a 4-byte baseline one + # byte at a time and drop every address. + req = build_scan_request( + spec, + NextScanType.INCREASED_VALUE_BY, + value_text="01", + previous_scan_length=4, + ) + assert req.length == 4 + + +def test_delta_scan_on_a_fixed_width_type_ignores_the_previous_length(): + req = build_scan_request( + INT4, + NextScanType.INCREASED_VALUE_BY, + value_text="1", + previous_scan_length=99, + ) + assert req.length == 4 + assert req.value == 1 + + def test_no_value_scan_falls_back_to_the_spec_width_without_a_previous_scan(): req = build_scan_request(BYTES, NextScanType.CHANGED_VALUE, value_text="") assert req.length == BYTES.length diff --git a/tests/app/test_app_smoke.py b/tests/app/test_app_smoke.py index 92edeb0..7a70377 100644 --- a/tests/app/test_app_smoke.py +++ b/tests/app/test_app_smoke.py @@ -337,6 +337,89 @@ def test_no_value_next_scan_refines_at_the_scanned_width(qtbot): panel.close() +@pytest.mark.skipif(not qtbot_available, reason="pytest-qt not installed.") +def test_promote_and_update_values_use_the_scanned_width(qtbot): + """ + Both act on the results already on screen, so both must use the width those + results were read at — not the Length readout, which follows the Value box + and is cleared outright by a no-value scan type. + """ + from PySide6.QtWidgets import QApplication + + from PyMemoryEditor.app.scan_types import NextScanType + from PyMemoryEditor.app.scanner_panel import SCAN_TYPE_CHOICES, ScannerPanel + + QApplication.instance() or QApplication([]) + panel = ScannerPanel() + qtbot.addWidget(panel) + + panel._type_combo.setCurrentText("String (UTF-8)") + panel._value_edit.setText("olá") # 4 UTF-8 bytes + panel._on_first_scan() + panel.set_has_results(True) + + # A no-value scan type clears the Value box, so the readout drops to 0. + changed = next( + i + for i, (_, t) in enumerate(SCAN_TYPE_CHOICES) + if t is NextScanType.CHANGED_VALUE + ) + panel._scan_combo.setCurrentIndex(changed) + assert panel._length_spin.value() == 0 + + # Promoting must still carry 4, not the spec's 16 — an entry read 16 bytes + # wide would pull 12 bytes of neighbouring memory into the cell. + spec, length = panel.current_spec_and_length() + assert spec.label == "String (UTF-8)" + assert length == 4 + + # Update Values is a read-only refresh: it re-reads at the scanned width + # even with a candidate for the next scan sitting in the box, and leaves + # the baseline alone. + widths = [] + panel.update_values_requested.connect(lambda r: widths.append(r.length)) + panel._scan_combo.setCurrentIndex(0) # back to Exact Value + panel._value_edit.setText("hi") # 2 bytes — a candidate, not a rescan + panel._on_update_values() + assert widths == [4] + assert panel._last_scan_length == 4 + + panel.close() + + +@pytest.mark.skipif(not qtbot_available, reason="pytest-qt not installed.") +def test_range_readout_covers_both_bounds(qtbot): + """A range sizes with max(lo, hi), so the upper bound moves the readout.""" + from PySide6.QtWidgets import QApplication + + from PyMemoryEditor import ScanTypesEnum + from PyMemoryEditor.app.scanner_panel import SCAN_TYPE_CHOICES, ScannerPanel + + QApplication.instance() or QApplication([]) + panel = ScannerPanel() + qtbot.addWidget(panel) + + panel._type_combo.setCurrentText("Byte Array (Hex)") + between = next( + i + for i, (_, t) in enumerate(SCAN_TYPE_CHOICES) + if t is ScanTypesEnum.VALUE_BETWEEN + ) + panel._scan_combo.setCurrentIndex(between) + + panel._value_edit.setText("AA") + panel._second_value_edit.setText("AA BB CC") + assert panel._length_spin.value() == 3 + assert panel._build_request().length == 3 + + # Leaving the range drops the upper bound from the width again. + panel._scan_combo.setCurrentIndex(0) + assert panel._length_spin.value() == 1 + assert panel._build_request().length == 1 + + panel.close() + + @pytest.mark.skipif(not qtbot_available, reason="pytest-qt not installed.") def test_promoting_an_aob_hit_uses_the_pattern_width(qtbot): """ From 3343c558d0a56dfaa857594a895c8e2c1b39d90c Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Thu, 27 Aug 2026 14:32:24 -0300 Subject: [PATCH 06/18] fix(app): confirm the scan baseline on completion, and stop re-deriving it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fourth round on the same class, so this one names the concept instead of patching another site: is_sized_by_value(spec) is now a single predicate the panel uses everywhere it previously spelled out "String or Byte Array, but not a pattern" by hand — which is what kept producing these. That gate was wrong in one place already: accepts_length_override is True for Regex too, so the width override caught it and made its Length field — the only editable one, and the user's own byte_length — inert after a first scan. Update Values and promote honour it again. Update Values was building a full request just to throw the value away (RefineScanWorker applies no comparison when filter_only is False). With the new empty-value rejection that turned into a hard regression: a Value box cleared by a no-value scan type aborted the refresh with "Invalid value" instead of refreshing. It builds the request directly now and can't fail. And the baseline width is adopted when a scan *lands*, not when it is dispatched: set_has_results is called by the owner on completion, so a Next Scan whose worker errors out no longer leaves a width the values on screen were never read at — which the following Changed/Unchanged refine would have compared against. Also: bulk edit collapsed AOB entries to a zero-width buffer exactly as _change_type did before the last commit, and manually adding an AOB address created one from the start; both ask for or keep a real width. Follow-up to #79. --- PyMemoryEditor/app/cheat_table.py | 13 +++- PyMemoryEditor/app/scanner_panel.py | 76 +++++++++++++------ tests/app/test_app_smoke.py | 113 ++++++++++++++++++++++++++++ 3 files changed, 177 insertions(+), 25 deletions(-) diff --git a/PyMemoryEditor/app/cheat_table.py b/PyMemoryEditor/app/cheat_table.py index faccfd2..db0b40d 100644 --- a/PyMemoryEditor/app/cheat_table.py +++ b/PyMemoryEditor/app/cheat_table.py @@ -531,7 +531,11 @@ def _on_edit_selected(self) -> None: if plan.spec is not None: entry.spec_label = plan.spec.label if not plan.spec.accepts_length_override: - entry.length = plan.spec.length + # `or entry.length`: the IDA pattern spec has no width + # of its own (the scanner derives it from the pattern), + # so keep the entry's rather than collapsing it to a + # zero-byte buffer. Same guard as _change_type. + entry.length = plan.spec.length or entry.length if plan.value_text is not None: spec = entry.spec @@ -766,13 +770,16 @@ def prompt_for_manual_entry(parent) -> Optional[CheatEntry]: return None spec = find_spec(spec_label) or VALUE_TYPES[0] + # An IDA pattern's spec length is 0 (the scanner derives the width from the + # pattern), and a manually added entry has no pattern to measure — so ask, + # seeded with a sensible default, rather than creating a zero-byte buffer. length = spec.length - if spec.accepts_length_override: + if spec.accepts_length_override or not spec.length: length, ok = QInputDialog.getInt( parent, "Add address", "Buffer length (bytes):", - value=spec.length, + value=spec.length or 4, minValue=1, maxValue=1024, ) diff --git a/PyMemoryEditor/app/scanner_panel.py b/PyMemoryEditor/app/scanner_panel.py index 4978ca0..0a4219f 100644 --- a/PyMemoryEditor/app/scanner_panel.py +++ b/PyMemoryEditor/app/scanner_panel.py @@ -44,7 +44,7 @@ is_next_scan_type, ) from .scan_worker import build_scan_request, ScanRequest -from .value_types import parse_value, VALUE_TYPES, find_spec +from .value_types import parse_value, ValueTypeSpec, VALUE_TYPES, find_spec # Shown in the (read-only) Length field of String / Byte Array before a value @@ -52,6 +52,18 @@ # there is genuinely no number to report yet. EMPTY_LENGTH_TEXT = "— (set by the value)" + +def is_sized_by_value(spec: ValueTypeSpec) -> bool: + """True for the types whose buffer width comes from the value entered. + + String (UTF-8) and Byte Array (Hex) only. The numeric types have a fixed + width, an IDA pattern derives one from the pattern, and a regex's Length + field is a genuine user-set ``byte_length`` — none of them may have their + width taken from a scan's value. + """ + return spec.pytype in (str, bytes) and not spec.is_pattern + + # The two scan types that take a second value; their width is max(lo, hi). RANGE_SCAN_TYPES = frozenset( (ScanTypesEnum.VALUE_BETWEEN, ScanTypesEnum.NOT_VALUE_BETWEEN) @@ -96,6 +108,11 @@ def __init__(self, parent=None): # Length readout can't stand in, since it tracks whatever value is in # the field right now, which the user is free to edit between scans. self._last_scan_length: Optional[int] = None + # Width of a scan that has been dispatched but hasn't landed yet. It is + # promoted above only when the owner reports results, so a scan that + # errors out or finds nothing leaves the values on screen described by + # the width they were actually read at. + self._pending_scan_length: Optional[int] = None self._build_ui() self._refresh_buttons() @@ -236,9 +253,18 @@ def _build_ui(self) -> None: self._on_scan_type_changed(0) def set_has_results(self, has_results: bool) -> None: + """Report whether the results table currently holds anything. + + Called by the owner once a scan has actually finished, which is what + makes it the right moment to adopt that scan's width as the baseline. + """ self._has_results = has_results - if not has_results: + if has_results: + if self._pending_scan_length: + self._last_scan_length = self._pending_scan_length + else: self._last_scan_length = None + self._pending_scan_length = None self._refresh_buttons() def set_busy(self, busy: bool) -> None: @@ -275,7 +301,7 @@ def _on_type_changed(self, label: str) -> None: is_pattern = spec.is_pattern is_regex = spec.is_regex - is_sized_by_value = spec.pytype in (str, bytes) and not is_pattern + sized_by_value = is_sized_by_value(spec) # Pattern modes reuse the "Value" line for the pattern and force the # scan-type combo to EXACT (Bigger/Smaller/Between don't apply). The @@ -292,7 +318,7 @@ def _on_type_changed(self, label: str) -> None: # followed by zeros". The field stays visible as a read-only readout # kept in sync by _sync_value_length / _on_value_text_changed. self._length_spin.setEnabled( - (spec.accepts_length_override and not is_pattern and not is_sized_by_value) + (spec.accepts_length_override and not is_pattern and not sized_by_value) or is_regex ) @@ -310,7 +336,7 @@ def _on_type_changed(self, label: str) -> None: # Every type but the value-sized pair owns a real number here, so clear # the "no width yet" slot the previous type may have opened (0 would # otherwise render as EMPTY_LENGTH_TEXT for e.g. "1 Byte (Int8)"). - if not is_sized_by_value: + if not sized_by_value: self._length_spin.setSpecialValueText("") self._length_spin.setMinimum(1) @@ -326,7 +352,7 @@ def _on_type_changed(self, label: str) -> None: self._length_spin.setMaximum(1024) self._length_spin.setValue(1) self._length_spin.setSuffix(" bytes") - elif is_sized_by_value: # String (UTF-8) / Byte Array (Hex) + elif sized_by_value: # String (UTF-8) / Byte Array (Hex) # Length tracks the typed value — raise the ceiling so long strings # aren't visually clamped, then mirror the current value's byte size. # @@ -398,7 +424,7 @@ def _sync_value_length(self, text: Optional[str] = None) -> None: # Only the value-sized types have a width to mirror; every other type # owns the field (a fixed width, or the regex's editable match width) # and must not have it overwritten from here. - if spec is None or spec.is_pattern or spec.pytype not in (str, bytes): + if spec is None or not is_sized_by_value(spec): return texts = [self._value_edit.text()] @@ -486,29 +512,35 @@ def _on_first_scan(self) -> None: return request = self._build_request() if request is not None: - self._last_scan_length = request.length + self._pending_scan_length = request.length self.first_scan_requested.emit(request) def _on_next_scan(self) -> None: request = self._build_request() if request is not None: # The refine rewrites every kept value at this width, so it becomes - # the baseline the next no-value comparison has to match. - self._last_scan_length = request.length + # the baseline the next no-value comparison has to match — once it + # has actually run. + self._pending_scan_length = request.length self.next_scan_requested.emit(request) def _on_update_values(self) -> None: - request = self._build_request() - if request is None: - return - # A read-only refresh of the results already on screen: it must re-read - # them at the width they were scanned at, and must not move that - # baseline — the Value box may hold a candidate for the *next* scan, - # and sizing the re-read from it would overwrite every stored value - # with a truncated read. - if request.spec.accepts_length_override and self._last_scan_length: - request.length = self._last_scan_length - self.update_values_requested.emit(request) + # A read-only refresh of the rows already on screen. RefineScanWorker + # applies no comparison when filter_only is False, so this needs no + # target value — and must not parse one: the Value box may be empty + # (a no-value scan type clears it outright), which would abort the + # refresh with "Invalid value" instead of refreshing. It re-reads at + # the width the rows were scanned at and leaves the baseline alone. + spec, length = self.current_spec_and_length() + self.update_values_requested.emit( + ScanRequest( + spec=spec, + length=length, + scan_type=ScanTypesEnum.EXACT_VALUE, + value=None, + writeable_only=self._writable_check.isChecked(), + ) + ) def current_spec_and_length(self): """Return the active (spec, length) pair for the Promote-to-Cheat-Table path.""" @@ -528,7 +560,7 @@ def current_spec_and_length(self): # clears outright (a 4-byte "olá" scan would promote at the spec's 16, # and the entry would read 12 bytes of neighbouring memory into the # cell on every poll tick). - if spec.accepts_length_override and self._last_scan_length: + if is_sized_by_value(spec) and self._last_scan_length: return spec, self._last_scan_length length = ( diff --git a/tests/app/test_app_smoke.py b/tests/app/test_app_smoke.py index 7a70377..f648ab0 100644 --- a/tests/app/test_app_smoke.py +++ b/tests/app/test_app_smoke.py @@ -387,6 +387,119 @@ def test_promote_and_update_values_use_the_scanned_width(qtbot): panel.close() +@pytest.mark.skipif(not qtbot_available, reason="pytest-qt not installed.") +def test_update_values_needs_no_value_and_regex_keeps_its_length_field(qtbot): + """ + "Update Values" applies no comparison, so it must refresh without a target + value — a Value box a no-value scan type cleared used to abort it with + "Invalid value". And the width override is for the value-sized types only: + Regex owns a genuinely editable Length field that must keep working. + """ + from PySide6.QtWidgets import QApplication, QMessageBox + + from PyMemoryEditor.app.scan_types import NextScanType + from PyMemoryEditor.app.scanner_panel import SCAN_TYPE_CHOICES, ScannerPanel + + QApplication.instance() or QApplication([]) + + dialogs = [] + original_warning = QMessageBox.warning + QMessageBox.warning = staticmethod(lambda *a, **k: dialogs.append(a[1:3])) + try: + panel = ScannerPanel() + qtbot.addWidget(panel) + + panel._type_combo.setCurrentText("String (UTF-8)") + panel._value_edit.setText("olá") + panel._on_first_scan() + panel.set_has_results(True) + + # A no-value scan type clears the Value box; switching back leaves it + # empty, which must not stop the refresh. + changed = next( + i + for i, (_, t) in enumerate(SCAN_TYPE_CHOICES) + if t is NextScanType.CHANGED_VALUE + ) + panel._scan_combo.setCurrentIndex(changed) + panel._scan_combo.setCurrentIndex(0) + + widths = [] + panel.update_values_requested.connect(lambda r: widths.append(r.length)) + panel._on_update_values() + assert widths == [4] + assert dialogs == [] + + panel.close() + + # Regex: the Length field is the user's byte_length, not a readout. + regex_panel = ScannerPanel() + qtbot.addWidget(regex_panel) + regex_panel._type_combo.setCurrentText("Regex (String)") + regex_panel._value_edit.setText("Player[0-9]+") + regex_panel._on_first_scan() + regex_panel.set_has_results(True) + regex_panel._length_spin.setValue(128) + + regex_widths = [] + regex_panel.update_values_requested.connect( + lambda r: regex_widths.append(r.length) + ) + regex_panel._on_update_values() + assert regex_widths == [128] + assert regex_panel.current_spec_and_length()[1] == 128 + + regex_panel.close() + finally: + QMessageBox.warning = original_warning + + +@pytest.mark.skipif(not qtbot_available, reason="pytest-qt not installed.") +def test_a_scan_that_never_lands_does_not_move_the_baseline(qtbot): + """ + The baseline describes the values on screen, so it may only advance when a + scan actually finishes. A Next Scan whose worker errors out (the owner never + reports results) must leave the previous width in place, or the following + no-value refine re-reads at a width the table's values were never read at. + """ + from PySide6.QtWidgets import QApplication + + from PyMemoryEditor.app.scan_types import NextScanType + from PyMemoryEditor.app.scanner_panel import SCAN_TYPE_CHOICES, ScannerPanel + + QApplication.instance() or QApplication([]) + panel = ScannerPanel() + qtbot.addWidget(panel) + + panel._type_combo.setCurrentText("Byte Array (Hex)") + panel._value_edit.setText("00 11") # 2 bytes + panel._on_first_scan() + panel.set_has_results(True) + assert panel._last_scan_length == 2 + + # Dispatch a 4-byte Next Scan that never completes (no set_has_results). + panel._value_edit.setText("00 11 22 33") + panel._on_next_scan() + assert panel._last_scan_length == 2 + + changed = next( + i + for i, (_, t) in enumerate(SCAN_TYPE_CHOICES) + if t is NextScanType.CHANGED_VALUE + ) + panel._scan_combo.setCurrentIndex(changed) + assert panel._build_request().length == 2 # still the width on screen + + # Once a scan does land, its width becomes the baseline. + panel._value_edit.setText("00 11 22 33") + panel._scan_combo.setCurrentIndex(0) + panel._on_next_scan() + panel.set_has_results(True) + assert panel._last_scan_length == 4 + + panel.close() + + @pytest.mark.skipif(not qtbot_available, reason="pytest-qt not installed.") def test_range_readout_covers_both_bounds(qtbot): """A range sizes with max(lo, hi), so the upper bound moves the readout.""" From 0d70f8f3ed18a981db1623da1a11b77b5adbb994 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Thu, 27 Aug 2026 15:28:18 -0300 Subject: [PATCH 07/18] fix(app): floor cheat-entry widths at the table's door MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chasing the zero-width AOB entry one call site at a time found two more: importing a cheat table whose rows were saved by a build that promoted AOB hits at the spec's 0 (`raw.get("length") or spec.length` is `0 or 0`), and the pointer-chain dialog, which — unlike the pointer-scan dialog right next to it — offered the pattern types in its "Read value as" combo, so `_current_spec()` handed back the spec's 0. Rather than a fourth guard, the floor now sits in add_entry: every entry enters the table through it, whichever way it was made — promoted from a scan, from either pointer dialog, added by hand, or loaded from JSON. A zero-width entry can't be spotted from the table (the Value column just reads back empty forever), so this is worth enforcing once at the boundary rather than trusting each producer. The pointer-chain dialog also gets the filter its sibling already had, with the same reasoning: reading a value "as a pattern" is meaningless when the address is already known. Follow-up to #79. --- PyMemoryEditor/app/cheat_table.py | 9 +++++++ PyMemoryEditor/app/pointer_chain_dialog.py | 2 ++ tests/app/test_app_cheat_entry.py | 29 ++++++++++++++++++++++ 3 files changed, 40 insertions(+) diff --git a/PyMemoryEditor/app/cheat_table.py b/PyMemoryEditor/app/cheat_table.py index db0b40d..204c276 100644 --- a/PyMemoryEditor/app/cheat_table.py +++ b/PyMemoryEditor/app/cheat_table.py @@ -197,6 +197,15 @@ def _build_ui(self) -> None: delete_shortcut.activated.connect(self._on_remove_selected) def add_entry(self, entry: CheatEntry) -> None: + # Every entry enters here, whichever way it was created — promoted from + # a scan, from a pointer dialog, added by hand, or loaded from JSON. A + # zero-width buffer reads back empty on every poll tick and can't be + # spotted from the table, so the floor is enforced once, at the door, + # rather than at each of those call sites. (The AOB pattern spec is the + # one whose declared length is 0 — the scanner derives its real width + # from the pattern.) + entry.length = max(1, int(entry.length)) + # If the address already exists, just refresh its description/type. for existing in self._entries: if existing.address == entry.address: diff --git a/PyMemoryEditor/app/pointer_chain_dialog.py b/PyMemoryEditor/app/pointer_chain_dialog.py index c261cfb..5c26bf1 100644 --- a/PyMemoryEditor/app/pointer_chain_dialog.py +++ b/PyMemoryEditor/app/pointer_chain_dialog.py @@ -216,6 +216,8 @@ def _build_ui(self) -> None: self._value_type_combo = QComboBox() for spec in VALUE_TYPES: + if spec.is_pattern: + continue # reading a value "as a pattern" is meaningless here self._value_type_combo.addItem(spec.label) form.addRow("Read value as:", self._value_type_combo) diff --git a/tests/app/test_app_cheat_entry.py b/tests/app/test_app_cheat_entry.py index 40528ef..0f61f59 100644 --- a/tests/app/test_app_cheat_entry.py +++ b/tests/app/test_app_cheat_entry.py @@ -83,3 +83,32 @@ def test_legacy_spec_label_key_is_accepted(): {"address": "0x10", "spec_label": VALUE_TYPES[0].label} ) assert restored.spec_label == VALUE_TYPES[0].label + + +def test_a_zero_width_entry_is_floored_at_the_table_door(): + """ + The AOB pattern spec declares length 0 (the scanner derives the real width + from the pattern), so every path that falls back to it — a legacy JSON row, + a bulk type change, a manual add — could mint a zero-byte entry that reads + back empty forever. add_entry is the single door they all go through. + + Driven against the unbound method with a stub ``self`` so this stays in the + pure-logic file: constructing a real CheatTable needs a process and starts + the poll worker. + """ + pytest.importorskip("PySide6") + + from types import SimpleNamespace + + from PyMemoryEditor.app.cheat_entry import CheatEntry + from PyMemoryEditor.app.cheat_table import CheatTable + + # A row saved by a build that promoted AOB hits at the spec's 0. + entry = CheatEntry.from_dict( + {"address": "0x1000", "spec_label": "AOB Pattern (IDA)", "length": 0} + ) + assert entry.length == 0 # the row really is zero-width on disk + + table = SimpleNamespace(_entries=[], _rebuild=lambda: None) + CheatTable.add_entry(table, entry) + assert table._entries[0].length == 1 From 4de2a7ea59ec50a8aee7666c71ce67664227d314 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Thu, 27 Aug 2026 15:58:29 -0300 Subject: [PATCH 08/18] fix(app): end the scan cycle cleanly, and reject the deltas that never worked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Last round's baseline fix promoted a pending width on any report of results, and I reasoned that only a completed scan could produce one. That was wrong: "Update Values" finishes through the same handler. So a Next Scan whose worker errored left its width behind, and the next plain refresh adopted it — 2-byte values on screen, baseline claiming 4, and the following Changed Value refine drops everything. The pending width is now discarded when the scan cycle ends (set_busy(False), which the owner emits after the completion signal), so only the scan that actually landed can have set it. "Increased/Decreased Value By" never worked on String / Byte Array: the comparator does `prev + exp`, which concatenates rather than adds and is never equal to the fixed-width value read back, while `prev - exp` raises TypeError that the refine worker swallows into "doesn't match". Every address was dropped and nothing said why. The combination is rejected with a message pointing at Changed / Unchanged Value, and last round's width branch for it goes away — it was sizing a comparison that could never be true. Also: the cheat table's "Change buffer length" dialog capped at 1024 while a promoted String / Byte Array entry can be as wide as the value scanned for, so opening it and pressing OK silently shrank the entry. Follow-up to #79. --- PyMemoryEditor/app/cheat_table.py | 6 +++++- PyMemoryEditor/app/scan_worker.py | 22 +++++++++++++++------- PyMemoryEditor/app/scanner_panel.py | 12 ++++++++++++ tests/app/test_app_scan_request.py | 28 ++++++++++++++++------------ tests/app/test_app_smoke.py | 12 +++++++++++- 5 files changed, 59 insertions(+), 21 deletions(-) diff --git a/PyMemoryEditor/app/cheat_table.py b/PyMemoryEditor/app/cheat_table.py index 204c276..240c869 100644 --- a/PyMemoryEditor/app/cheat_table.py +++ b/PyMemoryEditor/app/cheat_table.py @@ -694,7 +694,11 @@ def _change_length(self, row: int) -> None: "Length (bytes):", value=self._entries[row].length, minValue=1, - maxValue=1024, + # A String / Byte Array entry is promoted at the width of the value + # that was scanned for, which the scanner doesn't cap at 1024 — a + # tighter ceiling here would silently shrink such an entry for a + # user who merely opened the dialog and pressed OK. + maxValue=max(1024, self._entries[row].length), ) if not ok: return diff --git a/PyMemoryEditor/app/scan_worker.py b/PyMemoryEditor/app/scan_worker.py index a7e06f5..801151a 100644 --- a/PyMemoryEditor/app/scan_worker.py +++ b/PyMemoryEditor/app/scan_worker.py @@ -110,9 +110,24 @@ def build_scan_request( :param previous_scan_length: the width the scan that produced the current results used. Only the no-value comparisons read it, and only for the variable-width types, whose baseline is meaningless at another width. + (The ``*_BY`` deltas compare against the baseline too, but they are + rejected outright for those types — see below.) :raises ValueError: if a value/pattern fails to parse (message is user-facing — the caller picks the dialog title from ``spec.is_pattern``). """ + # "Increased/Decreased value BY" adds the delta to the baseline, which only + # means anything for a number: on str/bytes ``prev + exp`` concatenates (so + # the comparison is never true) and ``prev - exp`` raises TypeError, which + # the refine worker swallows into "doesn't match". Either way every address + # is dropped and the user is told nothing, so reject the combination here + # with a message instead. + if scan_type in DELTA_SCAN_TYPES and spec.pytype in (str, bytes): + raise ValueError( + "Increased/Decreased Value By adds a numeric amount to the previous " + "value, which doesn't apply to %s. Use Changed Value or Unchanged " + "Value to compare against the previous scan." % spec.label + ) + # Pattern path — value is the pattern, scan_type is always EXACT. For an # IDA pattern the length is irrelevant (derived from the pattern); for a # regex it carries byte_length (the match width) from the Length field. @@ -166,13 +181,6 @@ def build_scan_request( else: value, length = parse_value(spec, value_text) - # "Increased/Decreased value BY" carry a value — the delta — but still - # compare against the previous scan's baseline, so like the no-value - # comparisons they must re-read at that scan's width. Sizing the read from - # the delta text instead would re-read a 4-byte baseline 1 byte at a time. - if scan_type in DELTA_SCAN_TYPES and spec.accepts_length_override: - length = previous_scan_length or length - if not with_value: value = None # Used by callers that only need spec/length/scan_type. diff --git a/PyMemoryEditor/app/scanner_panel.py b/PyMemoryEditor/app/scanner_panel.py index 0a4219f..597113c 100644 --- a/PyMemoryEditor/app/scanner_panel.py +++ b/PyMemoryEditor/app/scanner_panel.py @@ -268,7 +268,19 @@ def set_has_results(self, has_results: bool) -> None: self._refresh_buttons() def set_busy(self, busy: bool) -> None: + """Report whether a scan is running. + + The falling edge ends the scan cycle, which is where a pending width + that was never adopted gets dropped. The owner emits it after the + completion signal (``finished`` follows ``finished_ok``), so a scan that + landed has already had its width promoted by ``set_has_results``; one + that errored out never will, and must not leave the width behind for + an unrelated later completion — an "Update Values" refresh reports + results too — to pick up. + """ self._busy = busy + if not busy: + self._pending_scan_length = None self._refresh_buttons() def use_snapshot_cache(self) -> bool: diff --git a/tests/app/test_app_scan_request.py b/tests/app/test_app_scan_request.py index c873ba1..7033bd2 100644 --- a/tests/app/test_app_scan_request.py +++ b/tests/app/test_app_scan_request.py @@ -189,18 +189,22 @@ def test_no_value_scan_ignores_the_length_field(spec): @pytest.mark.parametrize("spec", (BYTES, STR)) -def test_delta_scan_refines_at_the_previous_scan_width(spec): - # "Increased/Decreased value BY" carry a delta but still compare against the - # previous scan's baseline, so the re-read width is that scan's, not the - # delta text's — sizing from the delta would re-read a 4-byte baseline one - # byte at a time and drop every address. - req = build_scan_request( - spec, - NextScanType.INCREASED_VALUE_BY, - value_text="01", - previous_scan_length=4, - ) - assert req.length == 4 +@pytest.mark.parametrize( + "scan_type", (NextScanType.INCREASED_VALUE_BY, NextScanType.DECREASED_VALUE_BY) +) +def test_delta_scan_is_rejected_for_the_variable_width_types(spec, scan_type): + # "Increased/Decreased value BY" adds the delta to the baseline, which only + # means anything for a number: on str/bytes `prev + exp` concatenates (never + # equal to the fixed-width current value) and `prev - exp` raises TypeError, + # which the refine worker swallows into "doesn't match". Every address was + # silently dropped; the user gets a message now. + with pytest.raises(ValueError, match="doesn't apply"): + build_scan_request( + spec, + scan_type, + value_text="01", + previous_scan_length=4, + ) def test_delta_scan_on_a_fixed_width_type_ignores_the_previous_length(): diff --git a/tests/app/test_app_smoke.py b/tests/app/test_app_smoke.py index f648ab0..3d7071f 100644 --- a/tests/app/test_app_smoke.py +++ b/tests/app/test_app_smoke.py @@ -490,9 +490,19 @@ def test_a_scan_that_never_lands_does_not_move_the_baseline(qtbot): panel._scan_combo.setCurrentIndex(changed) assert panel._build_request().length == 2 # still the width on screen + # The scan cycle ending discards the width that never landed, so a later + # unrelated completion — "Update Values" reports results too — can't adopt + # it retroactively. + panel.set_busy(True) + panel.set_busy(False) + assert panel._pending_scan_length is None + panel._on_update_values() + panel.set_has_results(True) + assert panel._last_scan_length == 2 + # Once a scan does land, its width becomes the baseline. - panel._value_edit.setText("00 11 22 33") panel._scan_combo.setCurrentIndex(0) + panel._value_edit.setText("00 11 22 33") panel._on_next_scan() panel.set_has_results(True) assert panel._last_scan_length == 4 From 286b63098a735e599b56eed16c80e564b07a36d2 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Thu, 27 Aug 2026 16:47:19 -0300 Subject: [PATCH 09/18] fix(app): promote AOB hits as byte arrays, and tighten the baseline's edges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Giving AOB entries a real width made their Value cell live for the first time, and it was wired backwards: the cell formats the bytes it reads as hex, but parses an edit as a *pattern*, so typing "00" wrote ASCII 0x30 0x30 rather than the byte 0x00 — reported as a successful write. A pattern finds an address; it can't hold a value. Hits promote as Byte Array at the pattern's width now, so the cell reads and writes the same thing. The zero-width buffer used to make every such write fail, which is why this never showed. Two more edges on the pending-width adoption: * a cancelled refine still reports results (the worker breaks out of its loop and emits finished_ok with what it kept), but only the rows it reached were re-read at the new width. Neither width describes the table, so cancelling keeps the one most rows were read at; * a request the owner rejects before the busy cycle begins (its handler returns early) never reaches set_busy(False), so its width lingered. "Update Values" now records the width it re-reads at — which is what the table will hold once it lands, and overwrites anything stale. Also: the Length field's enable expression still tested a clause that can no longer be true, reading as if more types than Regex could make it editable. Regex is the only spec whose width is genuinely the user's. Follow-up to #79. --- PyMemoryEditor/app/scanner_panel.py | 41 ++++++++++++++++++++++------- tests/app/test_app_smoke.py | 27 ++++++++++++++----- 2 files changed, 52 insertions(+), 16 deletions(-) diff --git a/PyMemoryEditor/app/scanner_panel.py b/PyMemoryEditor/app/scanner_panel.py index 597113c..14bca7e 100644 --- a/PyMemoryEditor/app/scanner_panel.py +++ b/PyMemoryEditor/app/scanner_panel.py @@ -242,7 +242,7 @@ def _build_ui(self) -> None: self._cancel_btn = QPushButton("Cancel scan") self._cancel_btn.setObjectName("danger") - self._cancel_btn.clicked.connect(self.cancel_requested.emit) + self._cancel_btn.clicked.connect(self._on_cancel) buttons.addWidget(self._cancel_btn) layout.addWidget(buttons_box) @@ -329,10 +329,11 @@ def _on_type_changed(self, label: str) -> None: # which NUL-pads the target and silently searches for "the value # followed by zeros". The field stays visible as a read-only readout # kept in sync by _sync_value_length / _on_value_text_changed. - self._length_spin.setEnabled( - (spec.accepts_length_override and not is_pattern and not sized_by_value) - or is_regex - ) + # Only the regex type keeps an editable field: it is the one spec whose + # width (byte_length, the max match width) is genuinely the user's to + # set. String / Byte Array mirror their value, and everything else is + # fixed by the spec. + self._length_spin.setEnabled(is_regex) if is_regex: self._value_edit.setPlaceholderText( @@ -536,6 +537,15 @@ def _on_next_scan(self) -> None: self._pending_scan_length = request.length self.next_scan_requested.emit(request) + def _on_cancel(self) -> None: + # A cancelled refine still reports results (the worker breaks out of its + # loop and emits finished_ok with what it kept), but only the rows it + # reached were re-read at the new width — the rest still hold values + # recorded at the old one. Neither width describes the table, so keep + # the one the majority of rows were actually read at. + self._pending_scan_length = None + self.cancel_requested.emit() + def _on_update_values(self) -> None: # A read-only refresh of the rows already on screen. RefineScanWorker # applies no comparison when filter_only is False, so this needs no @@ -544,6 +554,12 @@ def _on_update_values(self) -> None: # refresh with "Invalid value" instead of refreshing. It re-reads at # the width the rows were scanned at and leaves the baseline alone. spec, length = self.current_spec_and_length() + # The refresh re-reads and patches every row at this width, so it is the + # width the table will hold once it lands. Recording it also overwrites + # anything left behind by a request the owner rejected before the busy + # cycle began (an early return in its handler), which would otherwise be + # adopted here in place of a width that was never scanned at. + self._pending_scan_length = length self.update_values_requested.emit( ScanRequest( spec=spec, @@ -559,12 +575,17 @@ def current_spec_and_length(self): spec = find_spec(self._type_combo.currentText()) if spec is None: spec = VALUE_TYPES[0] - # An IDA pattern has no Length field and a spec length of 0 (the scanner - # derives the width from the pattern), so a promoted AOB hit would get a - # zero-byte buffer that the cheat table then re-reads as empty on every - # poll tick. Measure the pattern instead — one token is one byte. + # An IDA pattern is a way to *find* an address, not a way to hold a + # value: the cheat table would format the bytes it reads back as hex but + # parse an edit as a pattern, so typing "00" into the cell writes ASCII + # 0x30 0x30 rather than the byte 0x00. Promote the hit as the Byte Array + # type instead — same width (one pattern token is one byte), and the + # cell then reads and writes the same thing. if spec.is_pattern and not spec.is_regex: - return spec, self._pattern_byte_length() + return ( + find_spec("Byte Array (Hex)") or spec, + self._pattern_byte_length(), + ) # The rows being promoted were read at the width their scan ran at, so # that is the width the cheat entry has to keep. The Length readout diff --git a/tests/app/test_app_smoke.py b/tests/app/test_app_smoke.py index 3d7071f..3399087 100644 --- a/tests/app/test_app_smoke.py +++ b/tests/app/test_app_smoke.py @@ -500,8 +500,16 @@ def test_a_scan_that_never_lands_does_not_move_the_baseline(qtbot): panel.set_has_results(True) assert panel._last_scan_length == 2 - # Once a scan does land, its width becomes the baseline. + # A cancelled refine reports results too (the worker emits finished_ok with + # what it kept), but only the rows it reached were re-read at the new width. panel._scan_combo.setCurrentIndex(0) + panel._value_edit.setText("00 11 22 33 44 55") + panel._on_next_scan() + panel._on_cancel() + panel.set_has_results(True) + assert panel._last_scan_length == 2 + + # Once a scan does land uncancelled, its width becomes the baseline. panel._value_edit.setText("00 11 22 33") panel._on_next_scan() panel.set_has_results(True) @@ -544,15 +552,18 @@ def test_range_readout_covers_both_bounds(qtbot): @pytest.mark.skipif(not qtbot_available, reason="pytest-qt not installed.") -def test_promoting_an_aob_hit_uses_the_pattern_width(qtbot): +def test_promoting_an_aob_hit_yields_an_editable_byte_array_entry(qtbot): """ - An IDA pattern has no Length field and a spec length of 0, so a promoted hit - would land in the cheat table as a zero-byte entry that reads back empty on - every poll tick. The width is the pattern's own — one token, one byte. + An IDA pattern finds an address; it can't hold a value. A pattern-typed + cheat entry formats the bytes it reads as hex but parses an edit as a + *pattern*, so typing "00" would write ASCII 0x30 0x30 instead of the byte + 0x00 — and the spec's own length is 0, which would make the entry read back + empty forever. Promote as Byte Array at the pattern's width instead. """ from PySide6.QtWidgets import QApplication from PyMemoryEditor.app.scanner_panel import ScannerPanel + from PyMemoryEditor.app.value_types import parse_value QApplication.instance() or QApplication([]) panel = ScannerPanel() @@ -562,9 +573,13 @@ def test_promoting_an_aob_hit_uses_the_pattern_width(qtbot): panel._value_edit.setText("48 8B ? ? 00") spec, length = panel.current_spec_and_length() - assert spec.label == "AOB Pattern (IDA)" + assert spec.label == "Byte Array (Hex)" assert length == 5 # five tokens, wildcards included + # The promoted spec round-trips a hex edit as the bytes it displays. + value, _ = parse_value(spec, "00", length) + assert value == b"\x00" + panel.close() From 45810e33afd81117108f003e546f3fcb9f65c9a0 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Thu, 27 Aug 2026 17:18:19 -0300 Subject: [PATCH 10/18] fix(app): keep pattern types out of the cheat table entirely MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-review of the whole diff, on the final state rather than commit by commit. Three things it turned up. The write-ASCII bug wasn't fixed, only patched at one of its three doors. Substituting Byte Array inside current_spec_and_length covered promotion, but adding an address by hand with the AOB type, changing an existing entry's type to AOB, and importing a JSON table written before that change all still produced a pattern-typed entry — whose cell displays hex and writes the ASCII spelling of what you type. A pattern is search syntax: a way to find an address, never a shape a value is read and written back as. The type pickers now offer HOLDABLE_TYPE_LABELS (the non-pattern specs, the same filter the pointer dialogs already use) and add_entry re-types anything that still arrives — it is the one door promote, manual add and JSON import all pass through. The substitution comes out of current_spec_and_length, which was also handing the wrong spec to "Update Values"; three now-unreachable `or`-guards for the AOB zero come out with it, since they implied a zero could still arrive. The delta rejection sat before the pattern short-circuit. Both pattern specs are bytes-typed, so an AOB scan with a delta type raised instead of forcing EXACT as the pattern path documents — and pointed the user at Changed/Unchanged Value, which are disabled in pattern mode. Moved below, where it means what it says. Also: "Update Values" carried two comments contradicting each other about whether it moves the baseline (it does, to the width it re-reads at), and the Length field's enable rule had two overlapping rationales left over from when the expression was more than `is_regex`. Follow-up to #79. --- PyMemoryEditor/app/cheat_table.py | 52 +++++++++++++++++---------- PyMemoryEditor/app/scan_worker.py | 29 +++++++++------- PyMemoryEditor/app/scanner_panel.py | 53 +++++++++++++--------------- tests/app/test_app_cheat_entry.py | 54 +++++++++++++++++++++++++++++ tests/app/test_app_smoke.py | 20 ++++------- 5 files changed, 134 insertions(+), 74 deletions(-) diff --git a/PyMemoryEditor/app/cheat_table.py b/PyMemoryEditor/app/cheat_table.py index 240c869..5fb6c1e 100644 --- a/PyMemoryEditor/app/cheat_table.py +++ b/PyMemoryEditor/app/cheat_table.py @@ -51,6 +51,13 @@ from .value_types import VALUE_TYPES, ValueTypeSpec, find_spec, parse_value +# The value types a cheat entry may hold. The pattern types (AOB / regex) are +# search *syntax* — a way to find an address, not a shape a value can be read +# and written back as — so they are never offered as an entry's type. The +# pointer dialogs filter their own "Read value as" lists the same way. +HOLDABLE_TYPE_LABELS = [s.label for s in VALUE_TYPES if not s.is_pattern] + + # Re-exported for backward compatibility with callers that imported the # poll-interval constant from this module before the split. _TICK_INTERVAL_MS = TICK_INTERVAL_MS @@ -206,6 +213,18 @@ def add_entry(self, entry: CheatEntry) -> None: # from the pattern.) entry.length = max(1, int(entry.length)) + # An IDA pattern is a way to *find* an address, never a way to hold a + # value: its parse() returns the pattern *text*, so editing a cell that + # displays hex would write ASCII "00" (0x30 0x30) instead of the byte + # 0x00. Substituting the Byte Array type keeps the width and makes the + # cell read and write the same thing. Done here so promote, manual add + # and JSON import are all covered; _change_type and the bulk edit can't + # reintroduce it because neither offers a pattern type any more. + if entry.spec.is_pattern and not entry.spec.is_regex: + byte_array = find_spec("Byte Array (Hex)") + if byte_array is not None: + entry.spec_label = byte_array.label + # If the address already exists, just refresh its description/type. for existing in self._entries: if existing.address == entry.address: @@ -540,11 +559,10 @@ def _on_edit_selected(self) -> None: if plan.spec is not None: entry.spec_label = plan.spec.label if not plan.spec.accepts_length_override: - # `or entry.length`: the IDA pattern spec has no width - # of its own (the scanner derives it from the pattern), - # so keep the entry's rather than collapsing it to a - # zero-byte buffer. Same guard as _change_type. - entry.length = plan.spec.length or entry.length + # Safe to take the spec's width verbatim: the picker + # only offers HOLDABLE_TYPE_LABELS, and the AOB pattern + # — the one spec declaring a length of 0 — isn't in it. + entry.length = plan.spec.length if plan.value_text is not None: spec = entry.spec @@ -667,7 +685,7 @@ def _copy_address(self, row: int) -> None: QGuiApplication.clipboard().setText(f"{self._entries[row].address:X}") def _change_type(self, row: int) -> None: - labels = [s.label for s in VALUE_TYPES] + labels = HOLDABLE_TYPE_LABELS current = ( labels.index(self._entries[row].spec_label) if self._entries[row].spec_label in labels @@ -681,10 +699,9 @@ def _change_type(self, row: int) -> None: self._entries[row].spec_label = chosen spec = find_spec(chosen) or VALUE_TYPES[0] if not spec.accepts_length_override: - # The IDA pattern spec has no width of its own (the scanner derives - # it from the pattern), so keep whatever the entry was promoted - # with rather than collapsing it to a zero-byte buffer. - self._entries[row].length = spec.length or self._entries[row].length + # Same as the bulk edit: `chosen` comes from HOLDABLE_TYPE_LABELS, + # so it can't be the zero-length AOB pattern spec. + self._entries[row].length = spec.length self._rebuild() def _change_length(self, row: int) -> None: @@ -775,7 +792,7 @@ def prompt_for_manual_entry(parent) -> Optional[CheatEntry]: QMessageBox.warning(parent, "Add address", "Invalid hex address.") return None - labels = [s.label for s in VALUE_TYPES] + labels = HOLDABLE_TYPE_LABELS spec_label, ok = QInputDialog.getItem( parent, "Add address", "Value type:", labels, 0, False ) @@ -783,16 +800,13 @@ def prompt_for_manual_entry(parent) -> Optional[CheatEntry]: return None spec = find_spec(spec_label) or VALUE_TYPES[0] - # An IDA pattern's spec length is 0 (the scanner derives the width from the - # pattern), and a manually added entry has no pattern to measure — so ask, - # seeded with a sensible default, rather than creating a zero-byte buffer. length = spec.length - if spec.accepts_length_override or not spec.length: + if spec.accepts_length_override: length, ok = QInputDialog.getInt( parent, "Add address", "Buffer length (bytes):", - value=spec.length or 4, + value=spec.length, minValue=1, maxValue=1024, ) @@ -867,9 +881,9 @@ def __init__(self, entries: List[CheatEntry], parent=None) -> None: self._type_chk = QCheckBox("Set value type") self._type_combo = QComboBox() - for s in VALUE_TYPES: - self._type_combo.addItem(s.label) - if first.spec_label in (s.label for s in VALUE_TYPES): + for label in HOLDABLE_TYPE_LABELS: + self._type_combo.addItem(label) + if first.spec_label in HOLDABLE_TYPE_LABELS: self._type_combo.setCurrentText(first.spec_label) self._type_combo.setEnabled(False) self._type_chk.toggled.connect(self._type_combo.setEnabled) diff --git a/PyMemoryEditor/app/scan_worker.py b/PyMemoryEditor/app/scan_worker.py index 801151a..afccc87 100644 --- a/PyMemoryEditor/app/scan_worker.py +++ b/PyMemoryEditor/app/scan_worker.py @@ -115,19 +115,6 @@ def build_scan_request( :raises ValueError: if a value/pattern fails to parse (message is user-facing — the caller picks the dialog title from ``spec.is_pattern``). """ - # "Increased/Decreased value BY" adds the delta to the baseline, which only - # means anything for a number: on str/bytes ``prev + exp`` concatenates (so - # the comparison is never true) and ``prev - exp`` raises TypeError, which - # the refine worker swallows into "doesn't match". Either way every address - # is dropped and the user is told nothing, so reject the combination here - # with a message instead. - if scan_type in DELTA_SCAN_TYPES and spec.pytype in (str, bytes): - raise ValueError( - "Increased/Decreased Value By adds a numeric amount to the previous " - "value, which doesn't apply to %s. Use Changed Value or Unchanged " - "Value to compare against the previous scan." % spec.label - ) - # Pattern path — value is the pattern, scan_type is always EXACT. For an # IDA pattern the length is irrelevant (derived from the pattern); for a # regex it carries byte_length (the match width) from the Length field. @@ -143,6 +130,22 @@ def build_scan_request( writeable_only=writeable_only, ) + # "Increased/Decreased value BY" adds the delta to the baseline, which only + # means anything for a number: on str/bytes ``prev + exp`` concatenates (so + # the comparison is never true) and ``prev - exp`` raises TypeError, which + # the refine worker swallows into "doesn't match". Either way every address + # is dropped and the user is told nothing, so reject the combination with a + # message instead. Checked *after* the pattern short-circuit above: the + # pattern specs are bytes-typed too, but they force EXACT regardless of the + # scan type passed, and the comparisons this message points at are disabled + # in pattern mode anyway. + if scan_type in DELTA_SCAN_TYPES and spec.pytype in (str, bytes): + raise ValueError( + "Increased/Decreased Value By adds a numeric amount to the previous " + "value, which doesn't apply to %s. Use Changed Value or Unchanged " + "Value to compare against the previous scan." % spec.label + ) + # Increased/Decreased/Changed/Unchanged compare the value read now against # the one the *previous* scan recorded, so they need no target value — but # they must re-read at the width that baseline was recorded with. Reading diff --git a/PyMemoryEditor/app/scanner_panel.py b/PyMemoryEditor/app/scanner_panel.py index 14bca7e..3b5bde3 100644 --- a/PyMemoryEditor/app/scanner_panel.py +++ b/PyMemoryEditor/app/scanner_panel.py @@ -321,18 +321,14 @@ def _on_type_changed(self, label: str) -> None: # token count), but a *regex* has no inferable width, so its Length # field stays enabled and supplies search_by_pattern's byte_length. # - # String (UTF-8) and Byte Array (Hex) lock the length field: the buffer - # width is the size of the value the user entered (the text's UTF-8 byte - # length, multi-byte aware / the number of hex bytes), so letting them - # override it would only allow truncating the value — which the - # fixed-width ctypes buffer rejects outright — or over-allocating it, - # which NUL-pads the target and silently searches for "the value - # followed by zeros". The field stays visible as a read-only readout - # kept in sync by _sync_value_length / _on_value_text_changed. - # Only the regex type keeps an editable field: it is the one spec whose - # width (byte_length, the max match width) is genuinely the user's to - # set. String / Byte Array mirror their value, and everything else is - # fixed by the spec. + # Regex is the one spec whose width is genuinely the user's to set (its + # byte_length is the max match width, which nothing can infer). String / + # Byte Array mirror the value they were given — overriding that could + # only truncate it, which the fixed-width ctypes buffer rejects, or + # over-allocate it, which NUL-pads the target into a silent search for + # "the value followed by zeros". Everything else is fixed by its spec. + # The field stays visible as a read-only readout throughout, kept in + # sync by _sync_value_length / _on_value_text_changed. self._length_spin.setEnabled(is_regex) if is_regex: @@ -551,14 +547,13 @@ def _on_update_values(self) -> None: # applies no comparison when filter_only is False, so this needs no # target value — and must not parse one: the Value box may be empty # (a no-value scan type clears it outright), which would abort the - # refresh with "Invalid value" instead of refreshing. It re-reads at - # the width the rows were scanned at and leaves the baseline alone. + # refresh with "Invalid value" instead of refreshing. spec, length = self.current_spec_and_length() # The refresh re-reads and patches every row at this width, so it is the - # width the table will hold once it lands. Recording it also overwrites - # anything left behind by a request the owner rejected before the busy - # cycle began (an early return in its handler), which would otherwise be - # adopted here in place of a width that was never scanned at. + # width the table holds once it lands — the same one it was scanned at, + # recorded here so that a width left behind by a request the owner + # rejected before the busy cycle began (an early return in its handler) + # is overwritten rather than adopted in its place. self._pending_scan_length = length self.update_values_requested.emit( ScanRequest( @@ -571,21 +566,21 @@ def _on_update_values(self) -> None: ) def current_spec_and_length(self): - """Return the active (spec, length) pair for the Promote-to-Cheat-Table path.""" + """Return the (spec, width) the rows currently on screen were read at. + + Used by the Promote-to-Cheat-Table path and by the "Update Values" + refresh — both act on those rows, so both need the width their scan + ran at rather than anything the Value box says now. (An IDA hit is + promoted as this spec and re-typed to Byte Array by + ``CheatTable.add_entry``, which is where every entry enters.) + """ spec = find_spec(self._type_combo.currentText()) if spec is None: spec = VALUE_TYPES[0] - # An IDA pattern is a way to *find* an address, not a way to hold a - # value: the cheat table would format the bytes it reads back as hex but - # parse an edit as a pattern, so typing "00" into the cell writes ASCII - # 0x30 0x30 rather than the byte 0x00. Promote the hit as the Byte Array - # type instead — same width (one pattern token is one byte), and the - # cell then reads and writes the same thing. + # An IDA pattern has no Length field and a spec length of 0 — the width + # of one match is the pattern's own, one token per byte. if spec.is_pattern and not spec.is_regex: - return ( - find_spec("Byte Array (Hex)") or spec, - self._pattern_byte_length(), - ) + return spec, self._pattern_byte_length() # The rows being promoted were read at the width their scan ran at, so # that is the width the cheat entry has to keep. The Length readout diff --git a/tests/app/test_app_cheat_entry.py b/tests/app/test_app_cheat_entry.py index 0f61f59..080c6be 100644 --- a/tests/app/test_app_cheat_entry.py +++ b/tests/app/test_app_cheat_entry.py @@ -112,3 +112,57 @@ def test_a_zero_width_entry_is_floored_at_the_table_door(): table = SimpleNamespace(_entries=[], _rebuild=lambda: None) CheatTable.add_entry(table, entry) assert table._entries[0].length == 1 + + +@pytest.mark.parametrize( + "entry_kwargs", + ( + # Promoted from an AOB scan, added by hand, or loaded from a JSON table + # written before the substitution existed — all three land in add_entry. + {"spec_label": "AOB Pattern (IDA)", "length": 5}, + {"spec_label": "AOB Pattern (IDA)", "length": 0}, + ), +) +def test_a_pattern_entry_is_retyped_so_its_cell_round_trips(entry_kwargs): + """ + An IDA pattern finds an address; it can't hold a value. Its ``parse`` + returns the pattern *text*, so a cell that displays hex would write ASCII + "00" (0x30 0x30) into the target instead of the byte 0x00 — reported as a + successful write. add_entry is the one door every entry enters through, so + the substitution happens there rather than at each producer. + """ + pytest.importorskip("PySide6") + + from types import SimpleNamespace + + from PyMemoryEditor.app.cheat_entry import CheatEntry + from PyMemoryEditor.app.cheat_table import CheatTable + from PyMemoryEditor.app.value_types import parse_value + from PyMemoryEditor.util.convert import prepare_write + + entry = CheatEntry(description="", address=0x1000, **entry_kwargs) + assert entry.spec.is_pattern # what a producer handed over + + table = SimpleNamespace(_entries=[], _rebuild=lambda: None) + CheatTable.add_entry(table, entry) + + stored = table._entries[0] + assert stored.spec_label == "Byte Array (Hex)" + assert stored.length >= 1 + + # Editing the cell now writes the byte it displays, not its ASCII spelling. + value, _ = parse_value(stored.spec, "00", stored.length) + assert value == b"\x00" + assert prepare_write(stored.spec.pytype, stored.length, value)[2] == b"\x00" + + +def test_the_cheat_table_never_offers_a_pattern_as_an_entry_type(): + """The type pickers must not let a user re-introduce what add_entry strips.""" + pytest.importorskip("PySide6") + + from PyMemoryEditor.app.cheat_table import HOLDABLE_TYPE_LABELS + from PyMemoryEditor.app.value_types import VALUE_TYPES, find_spec + + assert HOLDABLE_TYPE_LABELS + assert not any(find_spec(label).is_pattern for label in HOLDABLE_TYPE_LABELS) + assert len(HOLDABLE_TYPE_LABELS) == len([s for s in VALUE_TYPES if not s.is_pattern]) diff --git a/tests/app/test_app_smoke.py b/tests/app/test_app_smoke.py index 3399087..3a274ec 100644 --- a/tests/app/test_app_smoke.py +++ b/tests/app/test_app_smoke.py @@ -552,18 +552,16 @@ def test_range_readout_covers_both_bounds(qtbot): @pytest.mark.skipif(not qtbot_available, reason="pytest-qt not installed.") -def test_promoting_an_aob_hit_yields_an_editable_byte_array_entry(qtbot): +def test_promoting_an_aob_hit_uses_the_pattern_width(qtbot): """ - An IDA pattern finds an address; it can't hold a value. A pattern-typed - cheat entry formats the bytes it reads as hex but parses an edit as a - *pattern*, so typing "00" would write ASCII 0x30 0x30 instead of the byte - 0x00 — and the spec's own length is 0, which would make the entry read back - empty forever. Promote as Byte Array at the pattern's width instead. + An IDA pattern has no Length field and a spec length of 0, so the width of + one match has to come from the pattern itself — one token per byte, + wildcards included. (Re-typing the entry so its cell is editable is + ``CheatTable.add_entry``'s job; see the cheat-entry tests.) """ from PySide6.QtWidgets import QApplication from PyMemoryEditor.app.scanner_panel import ScannerPanel - from PyMemoryEditor.app.value_types import parse_value QApplication.instance() or QApplication([]) panel = ScannerPanel() @@ -573,12 +571,8 @@ def test_promoting_an_aob_hit_yields_an_editable_byte_array_entry(qtbot): panel._value_edit.setText("48 8B ? ? 00") spec, length = panel.current_spec_and_length() - assert spec.label == "Byte Array (Hex)" - assert length == 5 # five tokens, wildcards included - - # The promoted spec round-trips a hex edit as the bytes it displays. - value, _ = parse_value(spec, "00", length) - assert value == b"\x00" + assert spec.label == "AOB Pattern (IDA)" + assert length == 5 panel.close() From ee362916edddaa11125f1462b27cbe22b6924d8a Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Fri, 28 Aug 2026 00:09:42 -0300 Subject: [PATCH 11/18] feat(app): make the pattern types work as cheat-table entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts removing AOB / Regex from the cheat table in favour of fixing what was actually broken about them. A cheat entry is read and written every poll tick, so its type has to round-trip: format(bytes read) must parse back to the same bytes. The pattern specs failed that because `parse` answers a different question — "what am I searching for?" — and for an IDA pattern it hands back the pattern *text*, so a cell displaying `48 8B 00` wrote the ASCII spelling of that hex. The specs now answer both questions. `ValueTypeSpec.parse_write` turns cell text into the bytes to write, and takes the value last read at the address; `parse_value_for_write` picks it when a spec has one. The scanner keeps asking `parse_value` — it only ever searches — and the cheat table only ever writes, so it no longer imports `parse_value` at all. For an IDA pattern that means the tokens resolve to the bytes they name, and `?` keeps the byte already at that offset: `48 8B ? ? 90` over `48 8B 12 34 00` writes `48 8B 12 34 90`. That mirrors the wildcard's search meaning and is what makes a signature patchable — you name the bytes you mean to change and leave the operands alone. A wildcard with nothing read yet is refused with a message rather than writing a zero. For a regex, which names a set of byte strings rather than one, the cell text is taken literally, matching what the cell displays. `tokenize_pattern` comes out of `compile_pattern` so the search and write paths share one set of token rules and error messages. Follow-up to #79. --- PyMemoryEditor/app/cheat_table.py | 66 +++++++++++------------- PyMemoryEditor/app/value_types.py | 86 +++++++++++++++++++++++++++++++ PyMemoryEditor/util/pattern.py | 45 +++++++++++----- tests/app/test_app_cheat_entry.py | 84 ++++++++++++++++++------------ 4 files changed, 198 insertions(+), 83 deletions(-) diff --git a/PyMemoryEditor/app/cheat_table.py b/PyMemoryEditor/app/cheat_table.py index 5fb6c1e..bc32e84 100644 --- a/PyMemoryEditor/app/cheat_table.py +++ b/PyMemoryEditor/app/cheat_table.py @@ -48,14 +48,12 @@ from ._widgets import parse_hex_address, shutdown_worker_thread from .cheat_entry import CheatEntry from .cheat_poll_worker import TICK_INTERVAL_MS, _CheatPollWorker -from .value_types import VALUE_TYPES, ValueTypeSpec, find_spec, parse_value - - -# The value types a cheat entry may hold. The pattern types (AOB / regex) are -# search *syntax* — a way to find an address, not a shape a value can be read -# and written back as — so they are never offered as an entry's type. The -# pointer dialogs filter their own "Read value as" lists the same way. -HOLDABLE_TYPE_LABELS = [s.label for s in VALUE_TYPES if not s.is_pattern] +from .value_types import ( + VALUE_TYPES, + ValueTypeSpec, + find_spec, + parse_value_for_write, +) # Re-exported for backward compatibility with callers that imported the @@ -213,18 +211,6 @@ def add_entry(self, entry: CheatEntry) -> None: # from the pattern.) entry.length = max(1, int(entry.length)) - # An IDA pattern is a way to *find* an address, never a way to hold a - # value: its parse() returns the pattern *text*, so editing a cell that - # displays hex would write ASCII "00" (0x30 0x30) instead of the byte - # 0x00. Substituting the Byte Array type keeps the width and makes the - # cell read and write the same thing. Done here so promote, manual add - # and JSON import are all covered; _change_type and the bulk edit can't - # reintroduce it because neither offers a pattern type any more. - if entry.spec.is_pattern and not entry.spec.is_regex: - byte_array = find_spec("Byte Array (Hex)") - if byte_array is not None: - entry.spec_label = byte_array.label - # If the address already exists, just refresh its description/type. for existing in self._entries: if existing.address == entry.address: @@ -340,7 +326,9 @@ def _on_cell_changed(self, row: int, column: int) -> None: # Treat empty as "unfreeze and clear" — no-op. return try: - value, _length = parse_value(entry.spec, text, entry.length) + value, _length = parse_value_for_write( + entry.spec, text, entry.length, entry.last_value + ) except ValueError as exc: QMessageBox.warning(self, "Invalid Value", str(exc)) self._suspend_signals = True @@ -559,16 +547,17 @@ def _on_edit_selected(self) -> None: if plan.spec is not None: entry.spec_label = plan.spec.label if not plan.spec.accepts_length_override: - # Safe to take the spec's width verbatim: the picker - # only offers HOLDABLE_TYPE_LABELS, and the AOB pattern - # — the one spec declaring a length of 0 — isn't in it. - entry.length = plan.spec.length + # `or entry.length`: the AOB pattern spec declares a + # length of 0 — the scanner derives a match's width from + # the pattern, and an entry has none to derive from — so + # keep the width it already has. + entry.length = plan.spec.length or entry.length if plan.value_text is not None: spec = entry.spec try: - value, effective_length = parse_value( - spec, plan.value_text, entry.length + value, effective_length = parse_value_for_write( + spec, plan.value_text, entry.length, entry.last_value ) except ValueError as exc: failures.append((entry.address, str(exc))) @@ -685,7 +674,7 @@ def _copy_address(self, row: int) -> None: QGuiApplication.clipboard().setText(f"{self._entries[row].address:X}") def _change_type(self, row: int) -> None: - labels = HOLDABLE_TYPE_LABELS + labels = [s.label for s in VALUE_TYPES] current = ( labels.index(self._entries[row].spec_label) if self._entries[row].spec_label in labels @@ -699,9 +688,9 @@ def _change_type(self, row: int) -> None: self._entries[row].spec_label = chosen spec = find_spec(chosen) or VALUE_TYPES[0] if not spec.accepts_length_override: - # Same as the bulk edit: `chosen` comes from HOLDABLE_TYPE_LABELS, - # so it can't be the zero-length AOB pattern spec. - self._entries[row].length = spec.length + # Same as the bulk edit: the AOB pattern spec declares no width of + # its own, so the entry keeps the one it has. + self._entries[row].length = spec.length or self._entries[row].length self._rebuild() def _change_length(self, row: int) -> None: @@ -792,7 +781,7 @@ def prompt_for_manual_entry(parent) -> Optional[CheatEntry]: QMessageBox.warning(parent, "Add address", "Invalid hex address.") return None - labels = HOLDABLE_TYPE_LABELS + labels = [s.label for s in VALUE_TYPES] spec_label, ok = QInputDialog.getItem( parent, "Add address", "Value type:", labels, 0, False ) @@ -800,13 +789,16 @@ def prompt_for_manual_entry(parent) -> Optional[CheatEntry]: return None spec = find_spec(spec_label) or VALUE_TYPES[0] + # The AOB pattern spec declares a length of 0 (the scanner derives a match's + # width from the pattern), and an address added by hand has no pattern to + # measure — so ask for the width instead of minting a zero-byte buffer. length = spec.length - if spec.accepts_length_override: + if spec.accepts_length_override or not spec.length: length, ok = QInputDialog.getInt( parent, "Add address", "Buffer length (bytes):", - value=spec.length, + value=spec.length or 4, minValue=1, maxValue=1024, ) @@ -881,9 +873,9 @@ def __init__(self, entries: List[CheatEntry], parent=None) -> None: self._type_chk = QCheckBox("Set value type") self._type_combo = QComboBox() - for label in HOLDABLE_TYPE_LABELS: - self._type_combo.addItem(label) - if first.spec_label in HOLDABLE_TYPE_LABELS: + for s in VALUE_TYPES: + self._type_combo.addItem(s.label) + if first.spec_label in (s.label for s in VALUE_TYPES): self._type_combo.setCurrentText(first.spec_label) self._type_combo.setEnabled(False) self._type_chk.toggled.connect(self._type_combo.setEnabled) diff --git a/PyMemoryEditor/app/value_types.py b/PyMemoryEditor/app/value_types.py index 30b02ce..dac64dc 100644 --- a/PyMemoryEditor/app/value_types.py +++ b/PyMemoryEditor/app/value_types.py @@ -35,6 +35,13 @@ class ValueTypeSpec: # ``byte_length`` (the number of bytes one match consumes) that # ``search_by_pattern`` requires for regex input. is_regex: bool = False + # How cell text becomes the bytes to write back, for the types whose + # ``parse`` answers a different question — "what am I searching for?" + # rather than "what value is this?". Receives the text and the bytes last + # read at the address, so a wildcard can mean "leave that byte alone". + # ``None`` means ``parse`` already answers both, which is the case for + # every type that isn't a pattern. + parse_write: Optional[Callable[[str, Optional[bytes]], Any]] = None def _parse_bool(text: str) -> bool: @@ -160,6 +167,58 @@ def _parse_regex(text: str) -> bytes: return pattern +def _parse_pattern_write(text: str, current: Optional[bytes]) -> bytes: + """Turn an IDA pattern typed into a value cell into the bytes to write. + + ``_parse_pattern`` answers the scanner's question and hands back the + pattern *text*, which is not something that can be written anywhere — the + cell would store the ASCII spelling of the hex it displays. Here the same + tokens are resolved to the bytes they name. + + A ``?`` keeps whatever byte is already at that offset. That mirrors its + search meaning ("any byte") and is what makes a signature patchable: you + name the bytes you mean to change and leave the operands alone. It needs + the current contents, so a wildcard only works once the entry has been + read at least one poll tick. + """ + from PyMemoryEditor.util.pattern import tokenize_pattern + + tokens = tokenize_pattern(text) + wildcards = [index for index, token in enumerate(tokens) if token is None] + + if wildcards: + if current is None: + raise ValueError( + "A '?' keeps the byte that is already there, so it can't be " + "used before the value has been read at least once." + ) + if len(current) < wildcards[-1] + 1: + raise ValueError( + "'?' at byte %d has nothing to keep: only %d byte(s) were read " + "at this address. Widen the entry or spell the byte out." + % (wildcards[-1] + 1, len(current)) + ) + + return bytes( + current[index] if token is None else token # type: ignore[index] + for index, token in enumerate(tokens) + ) + + +def _parse_regex_write(text: str, current: Optional[bytes]) -> bytes: + """Turn a value cell's text into bytes for a regex-typed entry. + + A regex names a *set* of byte strings, so the pattern itself can't be + written back. What the cell shows is the text read at the address + (``_fmt_regex_match``), so an edit is taken literally — the same rule as + String (UTF-8) — and writes exactly the characters typed. + """ + del current # A literal write doesn't depend on what is there. + if not text: + raise ValueError("Empty value.") + return text.encode("utf-8") + + def _fmt_bytes(value: bytes) -> str: if value is None: return "" @@ -271,6 +330,7 @@ def _fmt_int(value): lambda v: "" if v is None else (v if isinstance(v, str) else _fmt_bytes(v)), accepts_length_override=False, is_pattern=True, + parse_write=_parse_pattern_write, ), # Text-regex scan — the "Value" input becomes a string regex (e.g. # ``Player[0-9]+``) UTF-8 encoded into the bytes pattern, and the Length @@ -286,6 +346,7 @@ def _fmt_int(value): accepts_length_override=True, is_pattern=True, is_regex=True, + parse_write=_parse_regex_write, ), ) @@ -327,3 +388,28 @@ def parse_value( # under-allocating would silently truncate the value the user typed. length = max(1, len(value.encode("utf-8"))) return value, length + + +def parse_value_for_write( + spec: ValueTypeSpec, + text: str, + length_override: Optional[int] = None, + current: Optional[bytes] = None, +) -> Tuple[Any, int]: + """Parse ``text`` as the value to **write** at an address. + + :func:`parse_value` answers the scanner's question ("what am I looking + for?"). For every type but the two pattern ones that is the same answer, + but a pattern's ``parse`` yields search syntax — an IDA pattern's text, or + a regex — which is not a value any address can hold. Those specs supply a + ``parse_write`` that resolves the cell text to concrete bytes instead, + given ``current``: the bytes last read there, which a ``?`` wildcard keeps. + + Used by the cheat table, whose cells are read *and* written; the scanner + only ever searches, so it stays on :func:`parse_value`. + """ + if spec.parse_write is None: + return parse_value(spec, text, length_override) + + value = spec.parse_write(text, current) + return value, max(1, len(value)) diff --git a/PyMemoryEditor/util/pattern.py b/PyMemoryEditor/util/pattern.py index 485a0f8..a6480dc 100644 --- a/PyMemoryEditor/util/pattern.py +++ b/PyMemoryEditor/util/pattern.py @@ -26,7 +26,7 @@ """ import re -from typing import Pattern, Tuple, Union +from typing import List, Optional, Pattern, Tuple, Union PatternLike = Union[str, bytes, "re.Pattern[bytes]"] @@ -84,33 +84,54 @@ def compile_pattern( "compiled re.Pattern, not %r" % type(pattern).__name__ ) - tokens = pattern.split() - if not tokens: - raise ValueError("Empty pattern.") + tokens = tokenize_pattern(pattern) parts = [] for token in tokens: - if token in ("?", "??"): + if token is None: # Single-byte wildcard. ``.`` together with re.DOTALL matches any # byte 0x00-0xFF without special-casing 0x0A. parts.append(b".") continue + # Escape the byte so e.g. 0x5C (backslash) or 0x28 ('(') don't get + # interpreted as regex meta chars. + parts.append(re.escape(bytes((token,)))) + + return re.compile(b"".join(parts), re.DOTALL), len(tokens) + + +def tokenize_pattern(pattern: str) -> List[Optional[int]]: + """Split an IDA-style hex pattern into one entry per byte. + + Each entry is the byte's value, or ``None`` for a ``?`` / ``??`` wildcard. + ``compile_pattern`` turns these into a regex; the app's cheat table uses + them to write a signature back, where a wildcard means "leave the byte + that is already there alone". Both need the same token rules and the same + error messages, so they share this. + + :raises ValueError: on an empty pattern or a malformed token. + """ + tokens = pattern.split() + if not tokens: + raise ValueError("Empty pattern.") + + parsed: List[Optional[int]] = [] + for token in tokens: + if token in ("?", "??"): + parsed.append(None) + continue if len(token) != 2: raise ValueError( "Pattern token %r is not two hex digits or a '?' wildcard. " "Example of a valid pattern: '48 8B ? ? 00'." % token ) try: - byte = bytes.fromhex(token) + parsed.append(bytes.fromhex(token)[0]) except ValueError as exc: raise ValueError( "Pattern token %r is not valid hex: %s" % (token, exc) ) - # Escape the byte so e.g. 0x5C (backslash) or 0x28 ('(') don't get - # interpreted as regex meta chars. - parts.append(re.escape(byte)) - - return re.compile(b"".join(parts), re.DOTALL), len(tokens) + return parsed -__all__ = ("compile_pattern", "PatternLike") +__all__ = ("compile_pattern", "tokenize_pattern", "PatternLike") diff --git a/tests/app/test_app_cheat_entry.py b/tests/app/test_app_cheat_entry.py index 080c6be..8322218 100644 --- a/tests/app/test_app_cheat_entry.py +++ b/tests/app/test_app_cheat_entry.py @@ -115,54 +115,70 @@ def test_a_zero_width_entry_is_floored_at_the_table_door(): @pytest.mark.parametrize( - "entry_kwargs", + "text, current, expected", ( - # Promoted from an AOB scan, added by hand, or loaded from a JSON table - # written before the substitution existed — all three land in add_entry. - {"spec_label": "AOB Pattern (IDA)", "length": 5}, - {"spec_label": "AOB Pattern (IDA)", "length": 0}, + # Plain hex writes the bytes it names — the same ones the cell shows. + ("48 8B 00", None, b"\x48\x8b\x00"), + # A '?' keeps the byte already there, so a signature can be patched + # without disturbing the operands around what you meant to change. + ("48 ? 00", b"\x11\x22\x33", b"\x48\x22\x00"), + ("? ? ?", b"\xaa\xbb\xcc", b"\xaa\xbb\xcc"), ), ) -def test_a_pattern_entry_is_retyped_so_its_cell_round_trips(entry_kwargs): +def test_an_aob_entry_writes_the_bytes_its_cell_displays(text, current, expected): """ - An IDA pattern finds an address; it can't hold a value. Its ``parse`` - returns the pattern *text*, so a cell that displays hex would write ASCII - "00" (0x30 0x30) into the target instead of the byte 0x00 — reported as a - successful write. add_entry is the one door every entry enters through, so - the substitution happens there rather than at each producer. + ``spec.parse`` answers the scanner's question and returns the pattern + *text*, which would write the ASCII spelling of the hex the cell displays + ("00" as 0x30 0x30). The write path asks ``parse_value_for_write`` + instead, which resolves the tokens to the bytes they name. """ pytest.importorskip("PySide6") - from types import SimpleNamespace - - from PyMemoryEditor.app.cheat_entry import CheatEntry - from PyMemoryEditor.app.cheat_table import CheatTable - from PyMemoryEditor.app.value_types import parse_value + from PyMemoryEditor.app.value_types import find_spec, parse_value_for_write from PyMemoryEditor.util.convert import prepare_write - entry = CheatEntry(description="", address=0x1000, **entry_kwargs) - assert entry.spec.is_pattern # what a producer handed over + spec = find_spec("AOB Pattern (IDA)") + value, _ = parse_value_for_write(spec, text, len(expected), current) + assert value == expected + # And survives the encode the backend performs on the way to the process. + assert prepare_write(spec.pytype, len(expected), value)[2] == expected - table = SimpleNamespace(_entries=[], _rebuild=lambda: None) - CheatTable.add_entry(table, entry) - stored = table._entries[0] - assert stored.spec_label == "Byte Array (Hex)" - assert stored.length >= 1 +def test_an_aob_wildcard_needs_something_to_keep(): + """A '?' means "leave that byte alone", so it needs a byte to leave alone.""" + pytest.importorskip("PySide6") + + from PyMemoryEditor.app.value_types import find_spec, parse_value_for_write + + spec = find_spec("AOB Pattern (IDA)") + with pytest.raises(ValueError, match="read at least once"): + parse_value_for_write(spec, "48 ? 00", 3, None) + + # Read, but not far enough to cover the wildcard's offset. + with pytest.raises(ValueError, match="nothing to keep"): + parse_value_for_write(spec, "48 8B ?", 3, b"\x11") + + +def test_a_regex_entry_writes_its_cell_text_literally(): + """ + A regex names a *set* of byte strings, so the pattern can't be written + back. The cell shows the text read at the address, so an edit is taken + literally — the same rule String (UTF-8) follows. + """ + pytest.importorskip("PySide6") + + from PyMemoryEditor.app.value_types import find_spec, parse_value_for_write - # Editing the cell now writes the byte it displays, not its ASCII spelling. - value, _ = parse_value(stored.spec, "00", stored.length) - assert value == b"\x00" - assert prepare_write(stored.spec.pytype, stored.length, value)[2] == b"\x00" + spec = find_spec("Regex (String)") + value, _ = parse_value_for_write(spec, "Player01", 64, b"Player42") + assert value == b"Player01" -def test_the_cheat_table_never_offers_a_pattern_as_an_entry_type(): - """The type pickers must not let a user re-introduce what add_entry strips.""" +def test_every_non_pattern_type_writes_exactly_what_it_searches_for(): + """``parse_write`` exists only where the two questions differ.""" pytest.importorskip("PySide6") - from PyMemoryEditor.app.cheat_table import HOLDABLE_TYPE_LABELS - from PyMemoryEditor.app.value_types import VALUE_TYPES, find_spec + from PyMemoryEditor.app.value_types import VALUE_TYPES - assert HOLDABLE_TYPE_LABELS - assert not any(find_spec(label).is_pattern for label in HOLDABLE_TYPE_LABELS) - assert len(HOLDABLE_TYPE_LABELS) == len([s for s in VALUE_TYPES if not s.is_pattern]) + for spec in VALUE_TYPES: + assert (spec.parse_write is not None) == spec.is_pattern, spec.label From 01457c8d6048a48b8f88f43e2f9b2966e10da37c Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Fri, 28 Aug 2026 11:29:13 -0300 Subject: [PATCH 12/18] fix(app): writing a value must not resize the entry that holds it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-review of the parse_write commit. The cheat table's bulk edit stores the width parse_value_for_write returns back onto the entry, and that width is how many bytes the row *reads* on every poll tick — set by the user, and none of a write's business. parse_value honours an explicit length_override for the types that accept one, so before parse_write existed no value edit ever resized an entry. The new path returned len(value) unconditionally, and Regex does accept an override: bulk-editing a 64-byte regex entry to "hi" collapsed it to 2 bytes, so the row then displayed 2 bytes of the address forever. It now follows the same override rule as parse_value. An IDA pattern is unaffected either way — it declines a length override, so the cheat table never writes the returned width onto the entry — but it now reports the pattern's own byte count rather than the search path's 0, which is not a size any buffer could have. --- PyMemoryEditor/app/value_types.py | 8 +++++ tests/app/test_app_cheat_entry.py | 50 +++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/PyMemoryEditor/app/value_types.py b/PyMemoryEditor/app/value_types.py index dac64dc..53841e8 100644 --- a/PyMemoryEditor/app/value_types.py +++ b/PyMemoryEditor/app/value_types.py @@ -407,9 +407,17 @@ def parse_value_for_write( Used by the cheat table, whose cells are read *and* written; the scanner only ever searches, so it stays on :func:`parse_value`. + + The returned width follows :func:`parse_value`'s rule — an explicit + ``length_override`` wins for the types that accept one. Writing a value to + an entry must not resize it: the cheat table stores this width back onto the + entry, and the entry's width is how many bytes it *reads* on every poll + tick, which the user set and the write has no business shrinking. """ if spec.parse_write is None: return parse_value(spec, text, length_override) value = spec.parse_write(text, current) + if spec.accepts_length_override and length_override is not None: + return value, max(1, int(length_override)) return value, max(1, len(value)) diff --git a/tests/app/test_app_cheat_entry.py b/tests/app/test_app_cheat_entry.py index 8322218..02a7720 100644 --- a/tests/app/test_app_cheat_entry.py +++ b/tests/app/test_app_cheat_entry.py @@ -182,3 +182,53 @@ def test_every_non_pattern_type_writes_exactly_what_it_searches_for(): for spec in VALUE_TYPES: assert (spec.parse_write is not None) == spec.is_pattern, spec.label + + +@pytest.mark.parametrize( + "label, text", + ( + ("String (UTF-8)", "hi"), + ("Byte Array (Hex)", "AA"), + ("Regex (String)", "hi"), + ), +) +def test_writing_a_value_never_resizes_the_entry(label, text): + """ + The cheat table stores the width ``parse_value_for_write`` returns back onto + the entry (bulk edit), and that width is how many bytes the row *reads* on + every poll tick. A write must not shrink it to the size of what was typed — + which is what the pattern specs started doing once they got their own + ``parse_write``, since Regex accepts a length override. + """ + pytest.importorskip("PySide6") + + from PyMemoryEditor.app.value_types import ( + find_spec, + parse_value, + parse_value_for_write, + ) + + spec = find_spec(label) + entry_width = 64 + _, write_width = parse_value_for_write(spec, text, entry_width, b"\x00" * 64) + + assert write_width == entry_width + # And it agrees with what the search path reports for the same override. + assert write_width == parse_value(spec, text, entry_width)[1] + + +def test_a_pattern_without_a_length_override_reports_its_own_width(): + """ + An IDA pattern declares no width, so with nothing to honour the write width + is the pattern's own byte count. Nothing stores it (the spec doesn't accept + an override, so the cheat table leaves the entry's width alone), but 0 — + what the search path reports — would be a nonsense buffer size. + """ + pytest.importorskip("PySide6") + + from PyMemoryEditor.app.value_types import find_spec, parse_value_for_write + + spec = find_spec("AOB Pattern (IDA)") + assert not spec.accepts_length_override + _, width = parse_value_for_write(spec, "48 8B ? 00", None, b"\x11" * 8) + assert width == 4 From 3f8fb98dd073531db1655de4d031ce7d21aa4909 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Fri, 28 Aug 2026 12:03:57 -0300 Subject: [PATCH 13/18] fix(app): keep the regex type where a value is read at a known address MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both pointer dialogs filtered their "Read value as" list by is_pattern, which lumps together two specs that behave nothing alike once the address is already known. An IDA pattern genuinely can't be offered: it declares a length of 0 — the scanner derives a match's width from the pattern — and refuses a length override, so the dialog read zero bytes and displayed nothing. A regex has neither problem: its width comes from the Length field, and it renders the bytes as text up to the first NUL, which "String (UTF-8)" does not. Reading b"Player42\0\xff\xfe\x01rest" shows "Player42" as a regex and "Player42\0��\x01rest" as a string, so it isn't a duplicate of anything else in the list. has_readable_width() replaces the is_pattern test and asks the question that actually matters — can this spec size a read at an address I already have? — derived from the spec rather than from a label. The cheat table keeps offering both, since an entry carries its own width. Pointer Scan had this filter before the branch and Pointer Chain gained it earlier in it; both were dropping the regex for the same wrong reason, so both are corrected. --- PyMemoryEditor/app/pointer_chain_dialog.py | 16 ++++++++-- PyMemoryEditor/app/pointer_scan_dialog.py | 16 ++++++++-- PyMemoryEditor/app/value_types.py | 16 ++++++++++ tests/app/test_app_value_types.py | 35 ++++++++++++++++++++++ 4 files changed, 77 insertions(+), 6 deletions(-) diff --git a/PyMemoryEditor/app/pointer_chain_dialog.py b/PyMemoryEditor/app/pointer_chain_dialog.py index 5c26bf1..96aa67a 100644 --- a/PyMemoryEditor/app/pointer_chain_dialog.py +++ b/PyMemoryEditor/app/pointer_chain_dialog.py @@ -47,7 +47,12 @@ from PyMemoryEditor import AbstractProcess from ._widgets import parse_hex_address, parse_offsets, resolve_base_address -from .value_types import VALUE_TYPES, ValueTypeSpec, find_spec +from .value_types import ( + VALUE_TYPES, + ValueTypeSpec, + find_spec, + has_readable_width, +) # Child of "PyMemoryEditor" — surfaced by the Log Console via propagation. @@ -216,8 +221,13 @@ def _build_ui(self) -> None: self._value_type_combo = QComboBox() for spec in VALUE_TYPES: - if spec.is_pattern: - continue # reading a value "as a pattern" is meaningless here + # The address is already known here, so the only spec that can't be + # offered is the one with no width of its own — an IDA pattern, + # which would read zero bytes. A regex is welcome: its width comes + # from the Length field and it renders the bytes as text up to the + # first NUL, which "String (UTF-8)" doesn't do. + if not has_readable_width(spec): + continue self._value_type_combo.addItem(spec.label) form.addRow("Read value as:", self._value_type_combo) diff --git a/PyMemoryEditor/app/pointer_scan_dialog.py b/PyMemoryEditor/app/pointer_scan_dialog.py index a51a6aa..c9139be 100644 --- a/PyMemoryEditor/app/pointer_scan_dialog.py +++ b/PyMemoryEditor/app/pointer_scan_dialog.py @@ -59,7 +59,12 @@ parse_hex_address, shutdown_worker_thread, ) -from .value_types import VALUE_TYPES, ValueTypeSpec, find_spec +from .value_types import ( + VALUE_TYPES, + ValueTypeSpec, + find_spec, + has_readable_width, +) _LOG = logging.getLogger(__name__) @@ -409,8 +414,13 @@ def _build_ui(self) -> None: # type into the cheat table on promotion. self._value_type_combo = QComboBox() for spec in VALUE_TYPES: - if spec.is_pattern: - continue # reading a value "as a pattern" is meaningless here + # The address is already known here, so the only spec that can't be + # offered is the one with no width of its own — an IDA pattern, + # which would read zero bytes. A regex is welcome: its width comes + # from the Length field and it renders the bytes as text up to the + # first NUL, which "String (UTF-8)" doesn't do. + if not has_readable_width(spec): + continue self._value_type_combo.addItem(spec.label) self._value_type_combo.currentTextChanged.connect(self._on_value_type_changed) form.addRow("Read value as:", self._value_type_combo) diff --git a/PyMemoryEditor/app/value_types.py b/PyMemoryEditor/app/value_types.py index 53841e8..f5f7ead 100644 --- a/PyMemoryEditor/app/value_types.py +++ b/PyMemoryEditor/app/value_types.py @@ -390,6 +390,22 @@ def parse_value( return value, length +def has_readable_width(spec: ValueTypeSpec) -> bool: + """True when the spec can size a read at an address the caller already has. + + Every spec but the IDA pattern can: the numeric types declare a width, and + str / bytes / regex take one from the caller. An IDA pattern derives a + match's width from the pattern itself, so at a bare address it has none — + it declares a length of 0 *and* refuses a length override, which is exactly + the combination this tests. Offering it where the address is already known + would read zero bytes and display nothing forever. + + Note this says nothing about a *cheat entry*, which carries its own width + and so can hold an IDA pattern quite happily (see ``parse_value_for_write``). + """ + return spec.length > 0 or spec.accepts_length_override + + def parse_value_for_write( spec: ValueTypeSpec, text: str, diff --git a/tests/app/test_app_value_types.py b/tests/app/test_app_value_types.py index 7a56aa1..a50167a 100644 --- a/tests/app/test_app_value_types.py +++ b/tests/app/test_app_value_types.py @@ -203,3 +203,38 @@ def test_format_round_trips(): assert INT4.format(123) == "123" assert BYTES.format(None) == "" assert INT4.format(None) == "" + + +def test_only_the_ida_pattern_cannot_size_a_read_at_a_known_address(): + """ + The pointer dialogs read a value at an address the user already has, so the + type list is filtered by this. Everything can size such a read except the + IDA pattern, which declares a length of 0 *and* refuses an override — it + would read zero bytes and display nothing. + + Regex is deliberately on the allowed side: its width comes from the Length + field, and it renders bytes as text up to the first NUL, which + "String (UTF-8)" does not. + """ + from PyMemoryEditor.app.value_types import VALUE_TYPES, has_readable_width + + refused = [s.label for s in VALUE_TYPES if not has_readable_width(s)] + assert refused == ["AOB Pattern (IDA)"] + + +def test_a_regex_read_stops_at_the_nul_a_string_read_runs_past(): + """The reason Regex stays offered where the address is already known.""" + import ctypes + + from PyMemoryEditor.app.value_types import find_spec + from PyMemoryEditor.util.convert import convert_from_byte_array + + raw = b"Player42\x00\xff\xfe\x01rest" + buffer = (ctypes.c_byte * len(raw))(*raw) + + def shown(label): + spec = find_spec(label) + return spec.format(convert_from_byte_array(buffer, spec.pytype, len(raw))) + + assert shown("Regex (String)") == "Player42" + assert shown("String (UTF-8)").startswith("Player42\x00") From c6d03c2517a1c0a554f4d41644a63c35cae49987 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Fri, 28 Aug 2026 15:32:36 -0300 Subject: [PATCH 14/18] fix(app): stop a type change from poisoning the value it left behind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An entry's last_value / frozen_value hold whatever the *previous* spec decoded, and nothing waits for a fresh poll tick after the type changes, so the new spec was being applied to the old type's value. Two ways out: * _rebuild formats it — _fmt_bytes(1234) raises TypeError from inside a Qt slot, so an Int32 row retyped to Byte Array or AOB left the table un-rebuilt; a frozen entry would also republish the old value to the poll worker under the new pytype; * the bulk edit's "set type" and "set value" run in the same pass, so an IDA pattern's wildcard reached len() on an int. That raised TypeError too, which neither call site's `except ValueError` catches. Both branches now forget the cached value when the label actually changes — the next tick refills it — and the pattern write path treats a non-bytes `current` as "nothing read yet", so it stays inside the ValueError contract its callers handle. Cancelling a *first* scan no longer discards its width. Both workers report partial results after a cancel, but only a refine leaves the rows at mixed widths; every address a first scan found, it found at its own width, and there is no earlier one to fall back on — so the next Changed/Unchanged refine went to the spec default, 16 for a String scanned at 4, which is the failure this branch exists to fix. _has_results tells the two apart. Also: an IDA pattern wider than the entry it is typed into was truncated by prepare_write without a word, while the same edit one byte short was refused by the wildcard check; it is refused now too. And current_spec_and_length still documented a re-typing that was reverted several commits ago. tokenize_pattern comes back out of util.pattern.__all__ — it exists so the scan and write paths share token rules, nothing outside the package consumes it, and neither the guide nor the API reference describes it. --- PyMemoryEditor/app/cheat_table.py | 26 ++++++++++-- PyMemoryEditor/app/scanner_panel.py | 29 +++++++++----- PyMemoryEditor/app/value_types.py | 30 +++++++++++--- PyMemoryEditor/util/pattern.py | 6 ++- tests/app/test_app_cheat_entry.py | 61 +++++++++++++++++++++++++++++ 5 files changed, 134 insertions(+), 18 deletions(-) diff --git a/PyMemoryEditor/app/cheat_table.py b/PyMemoryEditor/app/cheat_table.py index bc32e84..d06f178 100644 --- a/PyMemoryEditor/app/cheat_table.py +++ b/PyMemoryEditor/app/cheat_table.py @@ -544,8 +544,10 @@ def _on_edit_selected(self) -> None: if plan.description is not None: entry.description = plan.description - if plan.spec is not None: + if plan.spec is not None and plan.spec.label != entry.spec_label: entry.spec_label = plan.spec.label + _forget_value_read_as_another_type(entry) + if plan.spec is not None: if not plan.spec.accepts_length_override: # `or entry.length`: the AOB pattern spec declares a # length of 0 — the scanner derives a match's width from @@ -685,12 +687,15 @@ def _change_type(self, row: int) -> None: ) if not ok: return - self._entries[row].spec_label = chosen + entry = self._entries[row] + if chosen != entry.spec_label: + entry.spec_label = chosen + _forget_value_read_as_another_type(entry) spec = find_spec(chosen) or VALUE_TYPES[0] if not spec.accepts_length_override: # Same as the bulk edit: the AOB pattern spec declares no width of # its own, so the entry keeps the one it has. - self._entries[row].length = spec.length or self._entries[row].length + entry.length = spec.length or entry.length self._rebuild() def _change_length(self, row: int) -> None: @@ -762,6 +767,21 @@ def _on_import(self) -> None: QMessageBox.warning(self, "Import", f"Skipped a bad entry: {exc}") +def _forget_value_read_as_another_type(entry: CheatEntry) -> None: + """Drop the cached value after an entry's type changes. + + ``last_value`` and ``frozen_value`` hold whatever the *previous* spec + decoded — an int, a str, raw bytes. Nothing waits for a fresh poll tick + before the new spec is used on them, and a spec's ``format`` only accepts + what its own ``pytype`` produces: ``_fmt_bytes(1234)`` raises TypeError from + inside a Qt slot, and a frozen entry would be re-published to the poll + worker to write the old type's value through the new type's ``pytype``. + The next tick refills both, so forgetting them costs a single frame. + """ + entry.last_value = None + entry.frozen_value = None + + def prompt_for_manual_entry(parent) -> Optional[CheatEntry]: """Sequential QInputDialog flow for the "Add Address Manually" button.""" description, ok = QInputDialog.getText( diff --git a/PyMemoryEditor/app/scanner_panel.py b/PyMemoryEditor/app/scanner_panel.py index 3b5bde3..60505c3 100644 --- a/PyMemoryEditor/app/scanner_panel.py +++ b/PyMemoryEditor/app/scanner_panel.py @@ -534,12 +534,22 @@ def _on_next_scan(self) -> None: self.next_scan_requested.emit(request) def _on_cancel(self) -> None: - # A cancelled refine still reports results (the worker breaks out of its - # loop and emits finished_ok with what it kept), but only the rows it - # reached were re-read at the new width — the rest still hold values - # recorded at the old one. Neither width describes the table, so keep - # the one the majority of rows were actually read at. - self._pending_scan_length = None + # Both workers still report results after a cancel — they break out of + # the loop and emit finished_ok with what they have — so what the + # partial results are worth depends on which scan was running. + # + # A refine re-read only the rows it reached at the new width; the rest + # still hold values recorded at the old one, so neither width describes + # the table and the previous one (which most rows match) stands. + # + # A first scan is the opposite: every address it did find, it found at + # its own width, and there is no earlier width to fall back on. Dropping + # it would send the next Changed/Unchanged refine to the spec default — + # 16 for a String scanned at 4 — which is the failure this whole branch + # exists to fix. _has_results tells the two apart: a first scan only + # runs when there are none yet. + if self._has_results: + self._pending_scan_length = None self.cancel_requested.emit() def _on_update_values(self) -> None: @@ -570,9 +580,10 @@ def current_spec_and_length(self): Used by the Promote-to-Cheat-Table path and by the "Update Values" refresh — both act on those rows, so both need the width their scan - ran at rather than anything the Value box says now. (An IDA hit is - promoted as this spec and re-typed to Byte Array by - ``CheatTable.add_entry``, which is where every entry enters.) + ran at rather than anything the Value box says now. An IDA hit keeps + this spec: a pattern-typed entry reads and writes correctly now (see + ``parse_value_for_write``), and only its width has to come from the + pattern, since the spec declares none. """ spec = find_spec(self._type_combo.currentText()) if spec is None: diff --git a/PyMemoryEditor/app/value_types.py b/PyMemoryEditor/app/value_types.py index f5f7ead..2738ec3 100644 --- a/PyMemoryEditor/app/value_types.py +++ b/PyMemoryEditor/app/value_types.py @@ -41,7 +41,9 @@ class ValueTypeSpec: # read at the address, so a wildcard can mean "leave that byte alone". # ``None`` means ``parse`` already answers both, which is the case for # every type that isn't a pattern. - parse_write: Optional[Callable[[str, Optional[bytes]], Any]] = None + parse_write: Optional[ + Callable[[str, Optional[bytes], Optional[int]], Any] + ] = None def _parse_bool(text: str) -> bool: @@ -167,7 +169,9 @@ def _parse_regex(text: str) -> bytes: return pattern -def _parse_pattern_write(text: str, current: Optional[bytes]) -> bytes: +def _parse_pattern_write( + text: str, current: Optional[bytes], length_override: Optional[int] = None +) -> bytes: """Turn an IDA pattern typed into a value cell into the bytes to write. ``_parse_pattern`` answers the scanner's question and hands back the @@ -186,7 +190,21 @@ def _parse_pattern_write(text: str, current: Optional[bytes]) -> bytes: tokens = tokenize_pattern(text) wildcards = [index for index, token in enumerate(tokens) if token is None] + if length_override is not None and len(tokens) > length_override: + raise ValueError( + "Pattern is %d bytes but the entry holds %d. Widen the entry first, " + "or the extra bytes would be dropped without warning." + % (len(tokens), length_override) + ) + if wildcards: + # `current` is whatever the entry's spec produced when it was last + # polled, and a type change doesn't wait for a fresh tick — so it can + # still be the int/str/float the *previous* type read. Anything but + # bytes is treated as "nothing read yet" rather than reaching len() + # and raising a TypeError the callers' `except ValueError` won't catch. + if not isinstance(current, (bytes, bytearray)): + current = None if current is None: raise ValueError( "A '?' keeps the byte that is already there, so it can't be " @@ -205,7 +223,9 @@ def _parse_pattern_write(text: str, current: Optional[bytes]) -> bytes: ) -def _parse_regex_write(text: str, current: Optional[bytes]) -> bytes: +def _parse_regex_write( + text: str, current: Optional[bytes], length_override: Optional[int] = None +) -> bytes: """Turn a value cell's text into bytes for a regex-typed entry. A regex names a *set* of byte strings, so the pattern itself can't be @@ -213,7 +233,7 @@ def _parse_regex_write(text: str, current: Optional[bytes]) -> bytes: (``_fmt_regex_match``), so an edit is taken literally — the same rule as String (UTF-8) — and writes exactly the characters typed. """ - del current # A literal write doesn't depend on what is there. + del current, length_override # A literal write depends on neither. if not text: raise ValueError("Empty value.") return text.encode("utf-8") @@ -433,7 +453,7 @@ def parse_value_for_write( if spec.parse_write is None: return parse_value(spec, text, length_override) - value = spec.parse_write(text, current) + value = spec.parse_write(text, current, length_override) if spec.accepts_length_override and length_override is not None: return value, max(1, int(length_override)) return value, max(1, len(value)) diff --git a/PyMemoryEditor/util/pattern.py b/PyMemoryEditor/util/pattern.py index a6480dc..4e6062b 100644 --- a/PyMemoryEditor/util/pattern.py +++ b/PyMemoryEditor/util/pattern.py @@ -134,4 +134,8 @@ def tokenize_pattern(pattern: str) -> List[Optional[int]]: return parsed -__all__ = ("compile_pattern", "tokenize_pattern", "PatternLike") +# tokenize_pattern is deliberately absent: it exists so the scan and write +# paths share one set of token rules, and nothing outside the package consumes +# it. Exporting it would promise a documented, supported surface that neither +# the guide nor the API reference describes. +__all__ = ("compile_pattern", "PatternLike") diff --git a/tests/app/test_app_cheat_entry.py b/tests/app/test_app_cheat_entry.py index 02a7720..fdc11f5 100644 --- a/tests/app/test_app_cheat_entry.py +++ b/tests/app/test_app_cheat_entry.py @@ -232,3 +232,64 @@ def test_a_pattern_without_a_length_override_reports_its_own_width(): assert not spec.accepts_length_override _, width = parse_value_for_write(spec, "48 8B ? 00", None, b"\x11" * 8) assert width == 4 + + +@pytest.mark.parametrize("stale", (1234, "text", 3.14, True)) +def test_a_wildcard_refuses_a_value_left_by_another_type(stale): + """ + ``current`` is whatever the entry's spec decoded on the last poll tick, and + a bulk edit that changes the type *and* sets a value uses the new spec on + the old type's value in the same pass. Anything but bytes has to come back + as ValueError — the callers only catch that, so a TypeError would escape + the Qt slot and leave the table un-rebuilt. + """ + pytest.importorskip("PySide6") + + from PyMemoryEditor.app.value_types import find_spec, parse_value_for_write + + with pytest.raises(ValueError, match="read at least once"): + parse_value_for_write(find_spec("AOB Pattern (IDA)"), "48 ? 00", 3, stale) + + +def test_a_pattern_wider_than_its_entry_is_refused(): + """ + prepare_write truncates to the entry's width, so the extra bytes would be + dropped in silence — while the same edit one byte *short* is rejected by + the wildcard check. Report the mismatch instead. + """ + pytest.importorskip("PySide6") + + from PyMemoryEditor.app.value_types import find_spec, parse_value_for_write + + spec = find_spec("AOB Pattern (IDA)") + with pytest.raises(ValueError, match="Widen the entry"): + parse_value_for_write(spec, "48 8B 90 90 CC CC CC CC", 5, b"\x11" * 5) + + # Exactly as wide is fine, and so is narrower. + assert parse_value_for_write(spec, "48 8B 90 90 CC", 5, b"\x11" * 5)[0] + assert parse_value_for_write(spec, "48 8B", 5, b"\x11" * 5)[0] == b"\x48\x8b" + + +def test_changing_an_entry_type_forgets_the_value_the_old_one_read(): + """ + A spec's ``format`` only accepts what its own ``pytype`` produces, and + nothing waits for a fresh poll tick after a type change: formatting an + Int32's 1234 as a byte array raises TypeError inside a Qt slot, and a frozen + entry would republish the old type's value under the new ``pytype``. + """ + pytest.importorskip("PySide6") + + from PyMemoryEditor.app.cheat_entry import CheatEntry + from PyMemoryEditor.app.cheat_table import _forget_value_read_as_another_type + + entry = CheatEntry( + description="", address=0x1000, spec_label="4 Bytes (Int32)", length=4 + ) + entry.last_value = 1234 + entry.frozen_value = 1234 + + _forget_value_read_as_another_type(entry) + entry.spec_label = "Byte Array (Hex)" + + assert entry.last_value is None and entry.frozen_value is None + assert entry.spec.format(entry.last_value) == "" # would have raised TypeError From 4b27cac21a550fb38631db363217c2c776f67ad4 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Fri, 28 Aug 2026 16:38:45 -0300 Subject: [PATCH 15/18] fix(app): re-arm a frozen row, and refuse writes wider than the entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-ups from review of the previous commit, three of them consequences of it. Dropping frozen_value on a type change left `frozen` ticked with no baseline, and nothing re-adopted one: the poll worker skips a frozen entry whose frozen_value is None, so the Active box stayed on while the freeze silently never wrote again until the user toggled it. The next tick now adopts the first value read under the new type. A value wider than its entry was written short without a word — the cell kept showing all of it until the next tick corrected itself — because prepare_write treats the entry width as a hard truncating cap. The previous commit added exactly this guard for patterns and left String and Byte Array silently dropping the tail; all three refuse it now. An IDA pattern's promotion width was re-derived from the Value box, which stays editable in pattern mode, so editing the pattern after a scan promoted (or refreshed) at a width the hits were never found at — the same staleness _last_scan_length exists to prevent. The pattern path couldn't use it because a pattern request reports a length of 0, which never promoted; the dispatched width is now the token count. Also: the wildcard's out-of-range message read as though the entry were permanently too narrow, when what it measures is the last read, which a narrower write shortens until the next tick. --- PyMemoryEditor/app/cheat_table.py | 7 +++ PyMemoryEditor/app/scanner_panel.py | 23 ++++++++-- PyMemoryEditor/app/value_types.py | 28 ++++++++++-- tests/app/test_app_cheat_entry.py | 66 +++++++++++++++++++++++++++++ 4 files changed, 116 insertions(+), 8 deletions(-) diff --git a/PyMemoryEditor/app/cheat_table.py b/PyMemoryEditor/app/cheat_table.py index d06f178..b017332 100644 --- a/PyMemoryEditor/app/cheat_table.py +++ b/PyMemoryEditor/app/cheat_table.py @@ -406,6 +406,13 @@ def _on_values_ready(self, results) -> None: continue entry = self._entries[row] entry.last_value = value + # A frozen row whose baseline was dropped — its type changed, + # so what the old spec had read no longer means anything — + # re-adopts the first value read under the new one. The poll + # worker skips a frozen entry with no frozen_value, so without + # this the Active box stays ticked while nothing is written. + if entry.frozen and entry.frozen_value is None: + entry.frozen_value = value self._update_value_cell(row, entry) finally: self._suspend_signals = False diff --git a/PyMemoryEditor/app/scanner_panel.py b/PyMemoryEditor/app/scanner_panel.py index 60505c3..603abc4 100644 --- a/PyMemoryEditor/app/scanner_panel.py +++ b/PyMemoryEditor/app/scanner_panel.py @@ -521,7 +521,7 @@ def _on_first_scan(self) -> None: return request = self._build_request() if request is not None: - self._pending_scan_length = request.length + self._pending_scan_length = self._dispatched_width(request) self.first_scan_requested.emit(request) def _on_next_scan(self) -> None: @@ -530,9 +530,21 @@ def _on_next_scan(self) -> None: # The refine rewrites every kept value at this width, so it becomes # the baseline the next no-value comparison has to match — once it # has actually run. - self._pending_scan_length = request.length + self._pending_scan_length = self._dispatched_width(request) self.next_scan_requested.emit(request) + def _dispatched_width(self, request: ScanRequest) -> int: + """Width the rows this request finds will have been read at. + + Normally the request's own length. An IDA pattern is the exception: it + reports 0, because the scanner derives a match's width from the pattern + rather than from a field — so the width one hit occupies is the token + count, which is what a promoted row has to be read at. + """ + if request.spec.is_pattern and not request.spec.is_regex: + return self._pattern_byte_length() + return request.length + def _on_cancel(self) -> None: # Both workers still report results after a cancel — they break out of # the loop and emit finished_ok with what they have — so what the @@ -589,9 +601,12 @@ def current_spec_and_length(self): if spec is None: spec = VALUE_TYPES[0] # An IDA pattern has no Length field and a spec length of 0 — the width - # of one match is the pattern's own, one token per byte. + # of one match is the pattern's own, one token per byte. Prefer the + # width the scan ran at: the Value box stays editable in pattern mode, + # so re-deriving it here would read at a width the hits were never + # found at, which is the staleness _last_scan_length exists to avoid. if spec.is_pattern and not spec.is_regex: - return spec, self._pattern_byte_length() + return spec, self._last_scan_length or self._pattern_byte_length() # The rows being promoted were read at the width their scan ran at, so # that is the width the cheat entry has to keep. The Length readout diff --git a/PyMemoryEditor/app/value_types.py b/PyMemoryEditor/app/value_types.py index 2738ec3..31bb075 100644 --- a/PyMemoryEditor/app/value_types.py +++ b/PyMemoryEditor/app/value_types.py @@ -212,9 +212,9 @@ def _parse_pattern_write( ) if len(current) < wildcards[-1] + 1: raise ValueError( - "'?' at byte %d has nothing to keep: only %d byte(s) were read " - "at this address. Widen the entry or spell the byte out." - % (wildcards[-1] + 1, len(current)) + "'?' at byte %d has nothing to keep: the last read of this " + "address returned %d byte(s). Wait for the next refresh, or " + "spell the byte out." % (wildcards[-1] + 1, len(current)) ) return bytes( @@ -451,7 +451,27 @@ def parse_value_for_write( tick, which the user set and the write has no business shrinking. """ if spec.parse_write is None: - return parse_value(spec, text, length_override) + value, length = parse_value(spec, text, length_override) + # prepare_write treats bufflength as a hard cap and truncates past it + # — characters for str, bytes for bytes — so a value wider than the + # entry would be written short with no word said, while the cell keeps + # showing all of it until the next poll tick corrects itself. The + # pattern path already refuses this; so do the other two. + if ( + length_override is not None + and spec.pytype in (str, bytes) + and len(value) > length_override + ): + raise ValueError( + "Value is %d %s but the entry holds %d. Widen the entry first, " + "or the extra would be dropped without warning." + % ( + len(value), + "characters" if spec.pytype is str else "bytes", + length_override, + ) + ) + return value, length value = spec.parse_write(text, current, length_override) if spec.accepts_length_override and length_override is not None: diff --git a/tests/app/test_app_cheat_entry.py b/tests/app/test_app_cheat_entry.py index fdc11f5..6159740 100644 --- a/tests/app/test_app_cheat_entry.py +++ b/tests/app/test_app_cheat_entry.py @@ -293,3 +293,69 @@ def test_changing_an_entry_type_forgets_the_value_the_old_one_read(): assert entry.last_value is None and entry.frozen_value is None assert entry.spec.format(entry.last_value) == "" # would have raised TypeError + + +@pytest.mark.parametrize( + "label, too_wide, exactly_wide", + ( + ("Byte Array (Hex)", "DE AD BE EF 11 22", "DE AD BE EF"), + ("String (UTF-8)", "abcdef", "abcd"), + ), +) +def test_a_value_wider_than_its_entry_is_refused(label, too_wide, exactly_wide): + """ + ``prepare_write`` treats the entry width as a hard truncating cap, so a + wider value was written short in silence while the cell kept showing all of + it until the next poll tick. The pattern path already refused this; the two + variable-width types now do too, so the three behave alike. + """ + pytest.importorskip("PySide6") + + from PyMemoryEditor.app.value_types import find_spec, parse_value_for_write + + with pytest.raises(ValueError, match="Widen the entry"): + parse_value_for_write(find_spec(label), too_wide, 4, b"\x00" * 4) + + # Exactly as wide still goes through, at the entry's width. + value, width = parse_value_for_write(find_spec(label), exactly_wide, 4, b"\x00" * 4) + assert value and width == 4 + + +def test_a_frozen_row_rearms_after_its_type_changes(): + """ + Changing the type drops frozen_value — what the old spec read means nothing + under the new one — but leaves ``frozen`` ticked, and the poll worker skips + a frozen entry whose frozen_value is None. Without re-adopting the first + value read under the new type, the Active box stays on while nothing is + ever written again. + """ + pytest.importorskip("PySide6") + + from types import SimpleNamespace + + from PyMemoryEditor.app.cheat_entry import CheatEntry + from PyMemoryEditor.app.cheat_table import ( + CheatTable, + _forget_value_read_as_another_type, + ) + + entry = CheatEntry( + description="", address=0x1000, spec_label="4 Bytes (Int32)", length=4 + ) + entry.frozen = True + entry.frozen_value = 1234 + entry.last_value = 1234 + + _forget_value_read_as_another_type(entry) + entry.spec_label = "2 Bytes (Int16)" + assert entry.frozen and entry.frozen_value is None # freeze is inert here + + table = SimpleNamespace( + _entries=[entry], + _editing_row=lambda: None, + _update_value_cell=lambda row, e: None, + _suspend_signals=False, + ) + CheatTable._on_values_ready(table, [(0x1000, int, 4, 77)]) + + assert entry.frozen_value == 77 # re-armed on the next tick From 81cfedf8d00317309e7a6494aee845f36b116a8a Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Fri, 28 Aug 2026 16:58:24 -0300 Subject: [PATCH 16/18] fix(app): one width guard, measured in bytes, on every path to an address MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three misses from the same habit — fixing a case instead of the rule. add_entry's "address already exists" branch is the third place a spec_label changes, and the previous commit fixed the other two. Promote an address already in the table under a different type — from a scan or from either pointer dialog — and the value the old spec decoded survived into _rebuild, where the new spec's formatter met it: _fmt_bytes("hello") raises ValueError from inside add_entry itself. The width guard was in two places and wrong in both. On the parse_write path it existed only for IDA patterns, so a regex entry got exactly the silent truncation the guard was added to stop. And it counted len(value) — characters for str — against a width that is a byte count, so "ábc" (3 characters, 4 bytes) passed the check for a 3-byte entry and prepare_write put a byte through the far wall. There is now one guard, after the value exists, measuring what actually goes on the wire; it covers String, Byte Array, Regex and IDA patterns alike. Not changed: current_spec_and_length still reads the live Length field for a regex rather than _last_scan_length. That field is the user's byte_length and the only editable width in the app; an earlier review flagged the opposite — that preferring the scan width left it inert — and Next Scan is disabled in pattern mode, so no baseline comparison can be thrown off by it. Editing it and refreshing is the control working. --- PyMemoryEditor/app/cheat_table.py | 6 +++ PyMemoryEditor/app/value_types.py | 66 ++++++++++++++++--------------- tests/app/test_app_cheat_entry.py | 60 ++++++++++++++++++++++++---- 3 files changed, 94 insertions(+), 38 deletions(-) diff --git a/PyMemoryEditor/app/cheat_table.py b/PyMemoryEditor/app/cheat_table.py index b017332..2cb3711 100644 --- a/PyMemoryEditor/app/cheat_table.py +++ b/PyMemoryEditor/app/cheat_table.py @@ -215,6 +215,12 @@ def add_entry(self, entry: CheatEntry) -> None: for existing in self._entries: if existing.address == entry.address: existing.description = entry.description or existing.description + # Re-promoting an address that is already in the table is the + # third place a spec_label changes, and the cached value has to + # go with it here too — _rebuild formats it through the new + # spec on the way out of this method. + if entry.spec_label != existing.spec_label: + _forget_value_read_as_another_type(existing) existing.spec_label = entry.spec_label existing.length = entry.length self._rebuild() diff --git a/PyMemoryEditor/app/value_types.py b/PyMemoryEditor/app/value_types.py index 31bb075..2a79289 100644 --- a/PyMemoryEditor/app/value_types.py +++ b/PyMemoryEditor/app/value_types.py @@ -190,13 +190,6 @@ def _parse_pattern_write( tokens = tokenize_pattern(text) wildcards = [index for index, token in enumerate(tokens) if token is None] - if length_override is not None and len(tokens) > length_override: - raise ValueError( - "Pattern is %d bytes but the entry holds %d. Widen the entry first, " - "or the extra bytes would be dropped without warning." - % (len(tokens), length_override) - ) - if wildcards: # `current` is whatever the entry's spec produced when it was last # polled, and a type change doesn't wait for a fresh tick — so it can @@ -410,6 +403,19 @@ def parse_value( return value, length +def _written_width(value: Any) -> int: + """Bytes ``value`` occupies once written, or 0 when its spec sets the size. + + ``str`` is measured encoded: the entry's width is a byte count, so counting + characters would let a multibyte value overflow it. + """ + if isinstance(value, str): + return len(value.encode("utf-8")) + if isinstance(value, (bytes, bytearray)): + return len(value) + return 0 + + def has_readable_width(spec: ValueTypeSpec) -> bool: """True when the spec can size a read at an address the caller already has. @@ -452,28 +458,26 @@ def parse_value_for_write( """ if spec.parse_write is None: value, length = parse_value(spec, text, length_override) - # prepare_write treats bufflength as a hard cap and truncates past it - # — characters for str, bytes for bytes — so a value wider than the - # entry would be written short with no word said, while the cell keeps - # showing all of it until the next poll tick corrects itself. The - # pattern path already refuses this; so do the other two. - if ( - length_override is not None - and spec.pytype in (str, bytes) - and len(value) > length_override - ): - raise ValueError( - "Value is %d %s but the entry holds %d. Widen the entry first, " - "or the extra would be dropped without warning." - % ( - len(value), - "characters" if spec.pytype is str else "bytes", - length_override, - ) - ) - return value, length + else: + value = spec.parse_write(text, current, length_override) + length = ( + max(1, int(length_override)) + if spec.accepts_length_override and length_override is not None + else max(1, _written_width(value)) + ) - value = spec.parse_write(text, current, length_override) - if spec.accepts_length_override and length_override is not None: - return value, max(1, int(length_override)) - return value, max(1, len(value)) + # One guard for every path that reaches an address. An entry's length is a + # *byte* width — it is the bufflength each poll tick reads — while + # prepare_write treats it as a hard cap and truncates past it, counting + # characters for str. So a wider value was written short with no word said + # while the cell kept showing all of it, and a multibyte str could slip the + # other way: "ábc" is 3 characters but 4 bytes, one past a 3-byte entry. + # Measuring what actually goes on the wire covers both. + written = _written_width(value) + if length_override is not None and written > length_override: + raise ValueError( + "Value is %d bytes but the entry holds %d. Widen the entry first, " + "or the extra would be dropped without warning." + % (written, length_override) + ) + return value, length diff --git a/tests/app/test_app_cheat_entry.py b/tests/app/test_app_cheat_entry.py index 6159740..095f978 100644 --- a/tests/app/test_app_cheat_entry.py +++ b/tests/app/test_app_cheat_entry.py @@ -296,13 +296,20 @@ def test_changing_an_entry_type_forgets_the_value_the_old_one_read(): @pytest.mark.parametrize( - "label, too_wide, exactly_wide", + "label, width, too_wide, exactly_wide", ( - ("Byte Array (Hex)", "DE AD BE EF 11 22", "DE AD BE EF"), - ("String (UTF-8)", "abcdef", "abcd"), + ("Byte Array (Hex)", 4, "DE AD BE EF 11 22", "DE AD BE EF"), + ("String (UTF-8)", 4, "abcdef", "abcd"), + ("Regex (String)", 4, "ABCDEFGH", "ABCD"), + ("AOB Pattern (IDA)", 4, "DE AD BE EF 11", "DE AD BE EF"), + # "ábc" is 3 characters but 4 bytes, and an entry's width is a byte + # count — measuring characters would let it write one byte past. + ("String (UTF-8)", 3, "ábc", "abc"), ), ) -def test_a_value_wider_than_its_entry_is_refused(label, too_wide, exactly_wide): +def test_a_value_wider_than_its_entry_is_refused( + label, width, too_wide, exactly_wide +): """ ``prepare_write`` treats the entry width as a hard truncating cap, so a wider value was written short in silence while the cell kept showing all of @@ -313,12 +320,15 @@ def test_a_value_wider_than_its_entry_is_refused(label, too_wide, exactly_wide): from PyMemoryEditor.app.value_types import find_spec, parse_value_for_write + current = b"\x00" * width with pytest.raises(ValueError, match="Widen the entry"): - parse_value_for_write(find_spec(label), too_wide, 4, b"\x00" * 4) + parse_value_for_write(find_spec(label), too_wide, width, current) # Exactly as wide still goes through, at the entry's width. - value, width = parse_value_for_write(find_spec(label), exactly_wide, 4, b"\x00" * 4) - assert value and width == 4 + value, reported = parse_value_for_write( + find_spec(label), exactly_wide, width, current + ) + assert value and reported == width def test_a_frozen_row_rearms_after_its_type_changes(): @@ -359,3 +369,39 @@ def test_a_frozen_row_rearms_after_its_type_changes(): CheatTable._on_values_ready(table, [(0x1000, int, 4, 77)]) assert entry.frozen_value == 77 # re-armed on the next tick + + +def test_re_promoting_an_address_forgets_the_value_its_old_type_read(): + """ + add_entry's "address already exists" branch is the third place a + spec_label changes — promoting the same address again from a scan or from + a pointer dialog — and _rebuild formats the cached value through the new + spec on the way out, so the old type's value has to go here too. + """ + pytest.importorskip("PySide6") + + from types import SimpleNamespace + + from PyMemoryEditor.app.cheat_entry import CheatEntry + from PyMemoryEditor.app.cheat_table import CheatTable + + existing = CheatEntry( + description="", address=0x1000, spec_label="String (UTF-8)", length=8 + ) + existing.last_value = "hello" + existing.frozen = True + existing.frozen_value = "hello" + + table = SimpleNamespace(_entries=[existing], _rebuild=lambda: None) + CheatTable.add_entry( + table, + CheatEntry( + description="", address=0x1000, spec_label="Byte Array (Hex)", length=4 + ), + ) + + stored = table._entries[0] + assert stored.spec_label == "Byte Array (Hex)" + assert stored.last_value is None and stored.frozen_value is None + # _fmt_bytes("hello") would have raised ValueError inside add_entry. + assert stored.spec.format(stored.last_value) == "" From 1215f58c37c12bf389003af4eecb55829b4f91df Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Fri, 28 Aug 2026 17:23:52 -0300 Subject: [PATCH 17/18] fix(app): let the bulk edit write with the value it is replacing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three from review of the previous commit. Changing a row's type and writing a value in the same bulk edit failed every selected row: the type change forgets the cached value, and the write in the same iteration then had nothing for an IDA '?' to keep — "can't be used before the value has been read at least once", about bytes that were sitting right there. Byte Array → AOB doesn't change what those bytes mean, and doing the two steps separately worked, which is the tell. The write now captures the value before the forget runs. Forgetting stays unconditional: a switch that keeps the pytype can still change the width (Int32 → Int16), and parse_value_for_write already ignores a `current` the new type could not have produced. "Change buffer length" capped at max(1024, entry.length), so an entry already wider than 1024 — which is normal now that entries are promoted at the width of the value scanned for — could only ever be shrunk, while a wider value couldn't be written into it either, since the width guard refuses that. No way out from inside the app. It uses the same ceiling the scanner does. And the Type column showed the width only for specs that accept a length override, which excludes the IDA pattern — the one spec whose width this branch made real and enforced. Being told to widen an entry whose width is nowhere on screen isn't much of an instruction. --- PyMemoryEditor/app/cheat_table.py | 33 +++++++++++++++++++++++-------- tests/app/test_app_cheat_entry.py | 32 ++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 8 deletions(-) diff --git a/PyMemoryEditor/app/cheat_table.py b/PyMemoryEditor/app/cheat_table.py index 2cb3711..7142213 100644 --- a/PyMemoryEditor/app/cheat_table.py +++ b/PyMemoryEditor/app/cheat_table.py @@ -277,7 +277,11 @@ def _write_row(self, row: int, entry: CheatEntry) -> None: self._table.setItem(row, self.COL_ADDRESS, addr) type_label = entry.spec_label - if entry.spec.accepts_length_override: + # Show the width whenever it belongs to the entry rather than to the + # spec. An IDA pattern declares none (length 0) yet carries a real one + # here — writes are refused against it — so hiding it left the user + # told to "widen the entry" with no way to see what it holds. + if entry.spec.accepts_length_override or not entry.spec.length: type_label += f" · {entry.length}B" type_item = QTableWidgetItem(type_label) type_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable) @@ -557,6 +561,13 @@ def _on_edit_selected(self) -> None: if plan.description is not None: entry.description = plan.description + # What the row was showing before this plan touched it. A type + # change forgets it, but a write in the same pass still needs + # it: an IDA '?' keeps the byte that is already there, and + # Byte Array → AOB doesn't change what those bytes mean. + # parse_value_for_write ignores it when the type genuinely + # changed shape, so a stale int can't be misread as bytes. + current = entry.last_value if plan.spec is not None and plan.spec.label != entry.spec_label: entry.spec_label = plan.spec.label _forget_value_read_as_another_type(entry) @@ -572,7 +583,7 @@ def _on_edit_selected(self) -> None: spec = entry.spec try: value, effective_length = parse_value_for_write( - spec, plan.value_text, entry.length, entry.last_value + spec, plan.value_text, entry.length, current ) except ValueError as exc: failures.append((entry.address, str(exc))) @@ -718,11 +729,12 @@ def _change_length(self, row: int) -> None: "Length (bytes):", value=self._entries[row].length, minValue=1, - # A String / Byte Array entry is promoted at the width of the value - # that was scanned for, which the scanner doesn't cap at 1024 — a - # tighter ceiling here would silently shrink such an entry for a - # user who merely opened the dialog and pressed OK. - maxValue=max(1024, self._entries[row].length), + # Same ceiling the scanner uses for a value-sized width. Anything + # lower is a trap now that entries are promoted at the width of the + # value scanned for: a 2000-byte entry under a max(1024, length) + # ceiling could only ever be shrunk, and a wider value can't be + # written into it either — parse_value_for_write refuses that. + maxValue=2_147_483_647, ) if not ok: return @@ -781,7 +793,7 @@ def _on_import(self) -> None: def _forget_value_read_as_another_type(entry: CheatEntry) -> None: - """Drop the cached value after an entry's type changes. + """Drop the cached value when ``new_spec`` can't read what produced it. ``last_value`` and ``frozen_value`` hold whatever the *previous* spec decoded — an int, a str, raw bytes. Nothing waits for a fresh poll tick @@ -790,6 +802,11 @@ def _forget_value_read_as_another_type(entry: CheatEntry) -> None: inside a Qt slot, and a frozen entry would be re-published to the poll worker to write the old type's value through the new type's ``pytype``. The next tick refills both, so forgetting them costs a single frame. + + Unconditional: even a switch that keeps the ``pytype`` can change the + width, and a value decoded at the old one means nothing at the new. A + caller that still needs the bytes — the bulk edit writes a value in the + same pass — must capture them before calling. """ entry.last_value = None entry.frozen_value = None diff --git a/tests/app/test_app_cheat_entry.py b/tests/app/test_app_cheat_entry.py index 095f978..ff9120b 100644 --- a/tests/app/test_app_cheat_entry.py +++ b/tests/app/test_app_cheat_entry.py @@ -405,3 +405,35 @@ def test_re_promoting_an_address_forgets_the_value_its_old_type_read(): assert stored.last_value is None and stored.frozen_value is None # _fmt_bytes("hello") would have raised ValueError inside add_entry. assert stored.spec.format(stored.last_value) == "" + + +@pytest.mark.parametrize( + "old_label, current, expected", + ( + # Byte Array → AOB keeps what the bytes mean, so the wildcard has + # something to keep even though the type change forgot the cache. + ("Byte Array (Hex)", b"\x11\x22\x33\x44", b"\x48\x22\x33\x00"), + # Int32 → AOB doesn't: those bytes were never read as bytes. + ("4 Bytes (Int32)", 1234, None), + ), +) +def test_a_bulk_edit_that_retypes_and_writes_uses_the_value_it_replaced( + old_label, current, expected +): + """ + The bulk edit changes the type and writes a value in the same pass, and the + type change forgets the cached value — so the write has to have captured it + first, or an IDA '?' finds nothing to keep and every selected row fails. + A value the new type could never have produced is ignored rather than + misread, which is why the Int32 case still refuses. + """ + pytest.importorskip("PySide6") + + from PyMemoryEditor.app.value_types import find_spec, parse_value_for_write + + spec = find_spec("AOB Pattern (IDA)") + if expected is None: + with pytest.raises(ValueError, match="read at least once"): + parse_value_for_write(spec, "48 ? ? 00", 4, current) + else: + assert parse_value_for_write(spec, "48 ? ? 00", 4, current)[0] == expected From 92cfa8019c81ff6dfde8f05f9c8dac3f33a54e7f Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Fri, 28 Aug 2026 17:43:27 -0300 Subject: [PATCH 18/18] fix(app): don't corner the bulk edit, the width field, or a frozen row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three dead ends the previous commit's guards created, all found by review of it. A bulk edit that retypes rows to a wider type refused every one of them. Three Int32 rows retyped to String with "hello" hit "the entry holds 4 — widen the entry first", and there is no width field in the bulk dialog, nor a "change length" on a multi-row selection: no way out from inside the app. A retype to a variable-width spec now takes the width from the value it writes, which is what promotion does too. The width ceiling went from a too-tight 1024 to no bound at all, and the poll worker allocates the entry's width for every row on every 100 ms tick — one typo away from a 2 GB allocation the tick's blanket except swallows and retries forever. MAX_ENTRY_LENGTH bounds it at a megabyte, far past any value a scan produces, and the manual-add dialog uses the same number instead of disagreeing at 1024. Re-arming a frozen row on the next tick after a type change traded a no-op for a wrong write: with frozen_value gone and the box still ticked, the tick adopted whatever the address happened to hold and the app started pinning a value the user never chose. The type change releases the freeze now. The re-arm stays for the case it was written for — a box ticked before the first read, which is a deliberate action on an unchanged type. Not changed: a regex cell writes its text literally, so editing one that held non-UTF-8 bytes writes U+FFFD back and the old tail survives past the new text. That is how String (UTF-8) has always behaved — reads decode with errors="replace" and prepare_write never pads — so it is a property of writing text over binary, not of making regex writable. Changing it means changing what a string write means. --- PyMemoryEditor/app/cheat_table.py | 37 +++++++++++--- tests/app/test_app_cheat_entry.py | 85 ++++++++++++++++++++++++------- 2 files changed, 98 insertions(+), 24 deletions(-) diff --git a/PyMemoryEditor/app/cheat_table.py b/PyMemoryEditor/app/cheat_table.py index 7142213..5ab6ff7 100644 --- a/PyMemoryEditor/app/cheat_table.py +++ b/PyMemoryEditor/app/cheat_table.py @@ -568,7 +568,9 @@ def _on_edit_selected(self) -> None: # parse_value_for_write ignores it when the type genuinely # changed shape, so a stale int can't be misread as bytes. current = entry.last_value + retyped = False if plan.spec is not None and plan.spec.label != entry.spec_label: + retyped = True entry.spec_label = plan.spec.label _forget_value_read_as_another_type(entry) if plan.spec is not None: @@ -581,9 +583,18 @@ def _on_edit_selected(self) -> None: if plan.value_text is not None: spec = entry.spec + # A retype to a variable-width spec re-sizes the entry from + # the value, rather than inheriting the width of the type it + # replaced. Otherwise three Int32 rows retyped to String + # with "hello" all fail on "the entry holds 4 — widen it + # first", and the bulk dialog has no width field, nor does + # a multi-row selection offer one: a dead end. + cap: Optional[int] = entry.length + if retyped and spec.accepts_length_override: + cap = None try: value, effective_length = parse_value_for_write( - spec, plan.value_text, entry.length, current + spec, plan.value_text, cap, current ) except ValueError as exc: failures.append((entry.address, str(exc))) @@ -729,12 +740,9 @@ def _change_length(self, row: int) -> None: "Length (bytes):", value=self._entries[row].length, minValue=1, - # Same ceiling the scanner uses for a value-sized width. Anything - # lower is a trap now that entries are promoted at the width of the - # value scanned for: a 2000-byte entry under a max(1024, length) - # ceiling could only ever be shrunk, and a wider value can't be - # written into it either — parse_value_for_write refuses that. - maxValue=2_147_483_647, + # An entry already wider than the cap can still be shrunk from here; + # it just can't grow past it. + maxValue=max(MAX_ENTRY_LENGTH, self._entries[row].length), ) if not ok: return @@ -792,6 +800,15 @@ def _on_import(self) -> None: QMessageBox.warning(self, "Import", f"Skipped a bad entry: {exc}") +# Ceiling for an entry's buffer width. Entries are promoted at the width of the +# value scanned for, which the scanner doesn't cap, so 1024 was too tight — but +# the poll worker allocates this many bytes per entry on every 100 ms tick, so +# an unbounded field turns one typo into a multi-gigabyte allocation the tick's +# blanket except swallows and retries forever. A megabyte is far past any real +# value and still cheap to read ten times a second. +MAX_ENTRY_LENGTH = 1_048_576 + + def _forget_value_read_as_another_type(entry: CheatEntry) -> None: """Drop the cached value when ``new_spec`` can't read what produced it. @@ -807,9 +824,15 @@ def _forget_value_read_as_another_type(entry: CheatEntry) -> None: width, and a value decoded at the old one means nothing at the new. A caller that still needs the bytes — the bulk edit writes a value in the same pass — must capture them before calling. + + The freeze is released with it. Leaving the box ticked with no target would + make the next poll tick adopt whatever the address happens to hold, pinning + a value the user never chose; a released box is visible and re-arming it is + one click. """ entry.last_value = None entry.frozen_value = None + entry.frozen = False def prompt_for_manual_entry(parent) -> Optional[CheatEntry]: diff --git a/tests/app/test_app_cheat_entry.py b/tests/app/test_app_cheat_entry.py index ff9120b..fe33eab 100644 --- a/tests/app/test_app_cheat_entry.py +++ b/tests/app/test_app_cheat_entry.py @@ -331,34 +331,48 @@ def test_a_value_wider_than_its_entry_is_refused( assert value and reported == width -def test_a_frozen_row_rearms_after_its_type_changes(): +def test_changing_a_type_releases_the_freeze_instead_of_retargeting_it(): """ - Changing the type drops frozen_value — what the old spec read means nothing - under the new one — but leaves ``frozen`` ticked, and the poll worker skips - a frozen entry whose frozen_value is None. Without re-adopting the first - value read under the new type, the Active box stays on while nothing is - ever written again. + A type change drops frozen_value, and leaving ``frozen`` ticked would make + the next poll tick adopt whatever the address happens to hold as the new + freeze target — the app would start pinning a value the user never chose. + Releasing the box is visible and re-arming it is one click. """ pytest.importorskip("PySide6") - from types import SimpleNamespace - from PyMemoryEditor.app.cheat_entry import CheatEntry - from PyMemoryEditor.app.cheat_table import ( - CheatTable, - _forget_value_read_as_another_type, - ) + from PyMemoryEditor.app.cheat_table import _forget_value_read_as_another_type entry = CheatEntry( description="", address=0x1000, spec_label="4 Bytes (Int32)", length=4 ) entry.frozen = True - entry.frozen_value = 1234 - entry.last_value = 1234 + entry.frozen_value = 100 + entry.last_value = 100 _forget_value_read_as_another_type(entry) - entry.spec_label = "2 Bytes (Int16)" - assert entry.frozen and entry.frozen_value is None # freeze is inert here + + assert not entry.frozen + assert entry.frozen_value is None and entry.last_value is None + + +def test_a_row_frozen_before_its_first_read_arms_on_that_read(): + """ + Ticking Active before the first poll leaves frozen with no target — the + poll worker skips such an entry — so the first value read arms it. That is + a deliberate user action on an unchanged type, unlike the retype above. + """ + pytest.importorskip("PySide6") + + from types import SimpleNamespace + + from PyMemoryEditor.app.cheat_entry import CheatEntry + from PyMemoryEditor.app.cheat_table import CheatTable + + entry = CheatEntry( + description="", address=0x1000, spec_label="4 Bytes (Int32)", length=4 + ) + entry.frozen = True # ticked before anything was read table = SimpleNamespace( _entries=[entry], @@ -368,7 +382,7 @@ def test_a_frozen_row_rearms_after_its_type_changes(): ) CheatTable._on_values_ready(table, [(0x1000, int, 4, 77)]) - assert entry.frozen_value == 77 # re-armed on the next tick + assert entry.frozen_value == 77 def test_re_promoting_an_address_forgets_the_value_its_old_type_read(): @@ -437,3 +451,40 @@ def test_a_bulk_edit_that_retypes_and_writes_uses_the_value_it_replaced( parse_value_for_write(spec, "48 ? ? 00", 4, current) else: assert parse_value_for_write(spec, "48 ? ? 00", 4, current)[0] == expected + + +def test_a_bulk_retype_sizes_the_entry_from_the_value_it_writes(): + """ + A bulk edit that changes the type *and* sets a value has no width field — + and a multi-row selection offers no "change length" either — so inheriting + the replaced type's width made a widening retype a dead end: three Int32 + rows retyped to String with "hello" all failed on "the entry holds 4". + A variable-width spec takes its width from the value instead. + """ + pytest.importorskip("PySide6") + + from PyMemoryEditor.app.value_types import find_spec, parse_value_for_write + + spec = find_spec("String (UTF-8)") + + # What the entry inherited from Int32 would have refused it. + with pytest.raises(ValueError, match="Widen the entry"): + parse_value_for_write(spec, "hello", 4, None) + + # Sized from the value, as the retype path now asks for. + value, width = parse_value_for_write(spec, "hello", None, None) + assert value == "hello" and width == 5 + + +def test_an_entry_width_is_bounded_by_what_a_poll_tick_can_read(): + """ + The poll worker allocates the entry's width for every row on every 100 ms + tick, so an unbounded field turns one typo into a multi-gigabyte allocation + that the tick's blanket except swallows and retries forever. + """ + pytest.importorskip("PySide6") + + from PyMemoryEditor.app.cheat_table import MAX_ENTRY_LENGTH + + # Far above any value a scan produces, far below a problem allocation. + assert 1024 < MAX_ENTRY_LENGTH <= 16 * 1024 * 1024