diff --git a/PyMemoryEditor/app/cheat_table.py b/PyMemoryEditor/app/cheat_table.py index c2d16f9..5ab6ff7 100644 --- a/PyMemoryEditor/app/cheat_table.py +++ b/PyMemoryEditor/app/cheat_table.py @@ -48,7 +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 +from .value_types import ( + VALUE_TYPES, + ValueTypeSpec, + find_spec, + parse_value_for_write, +) # Re-exported for backward compatibility with callers that imported the @@ -197,10 +202,25 @@ 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: 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() @@ -257,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) @@ -312,7 +336,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 @@ -390,6 +416,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 @@ -528,16 +561,40 @@ def _on_edit_selected(self) -> None: if plan.description is not None: entry.description = plan.description - if plan.spec is not None: + # 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 + 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: if not plan.spec.accepts_length_override: - 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 + # 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( - spec, plan.value_text, entry.length + value, effective_length = parse_value_for_write( + spec, plan.value_text, cap, current ) except ValueError as exc: failures.append((entry.address, str(exc))) @@ -665,10 +722,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: - 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. + entry.length = spec.length or entry.length self._rebuild() def _change_length(self, row: int) -> None: @@ -678,7 +740,9 @@ def _change_length(self, row: int) -> None: "Length (bytes):", value=self._entries[row].length, minValue=1, - maxValue=1024, + # 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 @@ -736,6 +800,41 @@ 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. + + ``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. + + 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. + + 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]: """Sequential QInputDialog flow for the "Add Address Manually" button.""" description, ok = QInputDialog.getText( @@ -763,13 +862,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, ) diff --git a/PyMemoryEditor/app/pointer_chain_dialog.py b/PyMemoryEditor/app/pointer_chain_dialog.py index c261cfb..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,6 +221,13 @@ def _build_ui(self) -> None: self._value_type_combo = QComboBox() for spec in VALUE_TYPES: + # 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/scan_worker.py b/PyMemoryEditor/app/scan_worker.py index 2d0b303..afccc87 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 @@ -85,6 +90,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: @@ -93,11 +99,19 @@ 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. + + :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. + (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``). """ @@ -116,19 +130,35 @@ 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 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 current vs previous and need - # no target value — just the value shape (type + length). + # 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_override if length_override is not None else spec.length + length = ( + previous_scan_length + if spec.accepts_length_override and previous_scan_length + else spec.length + ) return ScanRequest( spec=spec, length=int(length), @@ -137,14 +167,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..603abc4 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,30 @@ 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, ValueTypeSpec, 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)" + + +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) +) SCAN_TYPE_CHOICES = ( @@ -79,6 +102,17 @@ 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 + # 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() @@ -119,14 +153,17 @@ 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) 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() @@ -205,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) @@ -216,11 +253,34 @@ 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 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: + """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: @@ -253,7 +313,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 + 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 @@ -261,15 +321,15 @@ 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. - self._length_spin.setEnabled( - (spec.accepts_length_override and not is_pattern and not is_string) - or is_regex - ) + # 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: self._value_edit.setPlaceholderText( @@ -282,6 +342,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 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 @@ -294,16 +361,22 @@ 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 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. + # + # 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._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._length_spin.setValue(0) + self._sync_value_length() else: self._length_spin.setMaximum(1024) self._length_spin.setValue(spec.length) @@ -332,30 +405,62 @@ 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. + # _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. + + 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. + + 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. 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 not None and spec.pytype is str and not spec.is_pattern: - self._sync_string_length(text) + # 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 not is_sized_by_value(spec): + return - def _sync_string_length(self, text: Optional[str] = None) -> None: - """Mirror the UTF-8 byte length of the value text into the length field. + 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()) - Matches ``parse_value``'s str rule (byte length, not character count) - so the read-only readout shows exactly the buffer width the scan uses. - """ - if text is None: - text = self._value_edit.text() - self._length_spin.setValue(max(1, len(text.encode("utf-8")))) + 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. @@ -393,6 +498,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, ) @@ -415,24 +521,117 @@ def _on_first_scan(self) -> None: return request = self._build_request() if request is not None: + self._pending_scan_length = self._dispatched_width(request) 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 — once it + # has actually run. + 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 + # 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: - request = self._build_request() - if request is not None: - 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. + 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 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( + 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.""" + """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 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: 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. 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._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 + # 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 is_sized_by_value(spec) and self._last_scan_length: + return spec, self._last_scan_length + length = ( self._length_spin.value() if spec.accepts_length_override else spec.length ) - return spec, int(length) + # 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: + """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..2a79289 100644 --- a/PyMemoryEditor/app/value_types.py +++ b/PyMemoryEditor/app/value_types.py @@ -35,6 +35,15 @@ 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], Optional[int]], Any] + ] = None def _parse_bool(text: str) -> bool: @@ -86,6 +95,23 @@ 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 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.") + return text + + def _parse_pattern(text: str) -> str: """Validate an IDA-style AOB pattern and return it verbatim. @@ -143,6 +169,69 @@ def _parse_regex(text: str) -> bytes: return pattern +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 + 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: + # `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 " + "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: 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( + 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], 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 + 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, length_override # A literal write depends on neither. + if not text: + raise ValueError("Empty value.") + return text.encode("utf-8") + + def _fmt_bytes(value: bytes) -> str: if value is None: return "" @@ -231,7 +320,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, ), @@ -254,6 +343,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 @@ -269,6 +359,7 @@ def _fmt_int(value): accepts_length_override=True, is_pattern=True, is_regex=True, + parse_write=_parse_regex_write, ), ) @@ -310,3 +401,83 @@ 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 _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. + + 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, + 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`. + + 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: + value, length = parse_value(spec, text, length_override) + 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)) + ) + + # 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/PyMemoryEditor/util/pattern.py b/PyMemoryEditor/util/pattern.py index 485a0f8..4e6062b 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,58 @@ 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 +# 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 40528ef..fe33eab 100644 --- a/tests/app/test_app_cheat_entry.py +++ b/tests/app/test_app_cheat_entry.py @@ -83,3 +83,408 @@ 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 + + +@pytest.mark.parametrize( + "text, current, expected", + ( + # 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_an_aob_entry_writes_the_bytes_its_cell_displays(text, current, expected): + """ + ``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 PyMemoryEditor.app.value_types import find_spec, parse_value_for_write + from PyMemoryEditor.util.convert import prepare_write + + 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 + + +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 + + spec = find_spec("Regex (String)") + value, _ = parse_value_for_write(spec, "Player01", 64, b"Player42") + assert value == b"Player01" + + +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.value_types import VALUE_TYPES + + 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 + + +@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 + + +@pytest.mark.parametrize( + "label, width, too_wide, exactly_wide", + ( + ("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, 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 + 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 + + current = b"\x00" * width + with pytest.raises(ValueError, match="Widen the entry"): + parse_value_for_write(find_spec(label), too_wide, width, current) + + # Exactly as wide still goes through, at the entry's width. + value, reported = parse_value_for_write( + find_spec(label), exactly_wide, width, current + ) + assert value and reported == width + + +def test_changing_a_type_releases_the_freeze_instead_of_retargeting_it(): + """ + 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 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.frozen = True + entry.frozen_value = 100 + entry.last_value = 100 + + _forget_value_read_as_another_type(entry) + + 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], + _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 + + +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) == "" + + +@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 + + +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 diff --git a/tests/app/test_app_scan_request.py b/tests/app/test_app_scan_request.py index 18723ac..7033bd2 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,104 @@ 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 + + +@pytest.mark.parametrize("spec", (BYTES, STR)) +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 + # address reports as Changed. + req = build_scan_request( + spec, + NextScanType.CHANGED_VALUE, + value_text="", + 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 + + +@pytest.mark.parametrize("spec", (BYTES, STR)) +@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(): + 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 def test_no_value_scan_type_drops_value(): @@ -149,6 +250,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 9e48b1a..3a274ec 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,364 @@ 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 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 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() == 0 + + # 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() + + +@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_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_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 + + # 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 + + # 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) + 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): + """ + 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 + + 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 + + panel.close() + + +@pytest.mark.skipif(not qtbot_available, reason="pytest-qt not installed.") +def test_length_readout_reports_no_width_until_a_value_is_entered(qtbot): + """ + 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 EMPTY_LENGTH_TEXT, ScannerPanel + from PyMemoryEditor.app.value_types import find_spec + + QApplication.instance() or QApplication([]) + panel = ScannerPanel() + qtbot.addWidget(panel) + + # 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 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.isEnabled() + 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() 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")