Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
1ad73c9
fix(app): infer the byte-array scan width from the value entered
JeanExtreme002 Aug 27, 2026
164a64e
fix(app): keep the length readout usable for next-scan and type switches
JeanExtreme002 Aug 27, 2026
14e79ef
fix(app): report no length at all until a value-sized scan has a value
JeanExtreme002 Aug 27, 2026
ba64e89
fix(app): refine no-value scans at the width their baseline was recor…
JeanExtreme002 Aug 27, 2026
2e20981
fix(app): size every operation on existing results by the scan that m…
JeanExtreme002 Aug 27, 2026
3343c55
fix(app): confirm the scan baseline on completion, and stop re-derivi…
JeanExtreme002 Aug 27, 2026
0d70f8f
fix(app): floor cheat-entry widths at the table's door
JeanExtreme002 Aug 27, 2026
4de2a7e
fix(app): end the scan cycle cleanly, and reject the deltas that neve…
JeanExtreme002 Aug 27, 2026
286b630
fix(app): promote AOB hits as byte arrays, and tighten the baseline's…
JeanExtreme002 Aug 27, 2026
45810e3
fix(app): keep pattern types out of the cheat table entirely
JeanExtreme002 Aug 27, 2026
ee36291
feat(app): make the pattern types work as cheat-table entries
JeanExtreme002 Aug 28, 2026
01457c8
fix(app): writing a value must not resize the entry that holds it
JeanExtreme002 Aug 28, 2026
3f8fb98
fix(app): keep the regex type where a value is read at a known address
JeanExtreme002 Aug 28, 2026
908de18
Merge branch 'main' into fix/byte-array-infers-length
JeanExtreme002 Aug 28, 2026
c6d03c2
fix(app): stop a type change from poisoning the value it left behind
JeanExtreme002 Aug 28, 2026
4b27cac
fix(app): re-arm a frozen row, and refuse writes wider than the entry
JeanExtreme002 Aug 28, 2026
81cfedf
fix(app): one width guard, measured in bytes, on every path to an add…
JeanExtreme002 Aug 28, 2026
1215f58
fix(app): let the bulk edit write with the value it is replacing
JeanExtreme002 Aug 28, 2026
92cfa80
fix(app): don't corner the bulk edit, the width field, or a frozen row
JeanExtreme002 Aug 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 114 additions & 12 deletions PyMemoryEditor/app/cheat_table.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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()
Expand DownExpand Up@@ -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)
Expand DownExpand Up@@ -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
Expand DownExpand Up@@ -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
Expand DownExpand Up@@ -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)))
Expand DownExpand Up@@ -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:
Expand All@@ -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
Expand DownExpand Up@@ -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(
Expand DownExpand Up@@ -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,
)
Expand Down
14 changes: 13 additions & 1 deletion PyMemoryEditor/app/pointer_chain_dialog.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand DownExpand Up@@ -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)

Expand Down
16 changes: 13 additions & 3 deletions PyMemoryEditor/app/pointer_scan_dialog.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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__)
Expand DownExpand Up@@ -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)
Expand Down
78 changes: 58 additions & 20 deletions PyMemoryEditor/app/scan_worker.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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


Expand DownExpand Up@@ -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:
Expand All@@ -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``).
"""
Expand All@@ -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),
Expand All@@ -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.
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
fix(app): infer the byte-array scan width from the value entered by JeanExtreme002 · Pull Request #87 · JeanExtreme002/PyMemoryEditor · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
1ad73c9
fix(app): infer the byte-array scan width from the value entered
JeanExtreme002 Aug 27, 2026
164a64e
fix(app): keep the length readout usable for next-scan and type switches
JeanExtreme002 Aug 27, 2026
14e79ef
fix(app): report no length at all until a value-sized scan has a value
JeanExtreme002 Aug 27, 2026
ba64e89
fix(app): refine no-value scans at the width their baseline was recor…
JeanExtreme002 Aug 27, 2026
2e20981
fix(app): size every operation on existing results by the scan that m…
JeanExtreme002 Aug 27, 2026
3343c55
fix(app): confirm the scan baseline on completion, and stop re-derivi…
JeanExtreme002 Aug 27, 2026
0d70f8f
fix(app): floor cheat-entry widths at the table's door
JeanExtreme002 Aug 27, 2026
4de2a7e
fix(app): end the scan cycle cleanly, and reject the deltas that neve…
JeanExtreme002 Aug 27, 2026
286b630
fix(app): promote AOB hits as byte arrays, and tighten the baseline's…
JeanExtreme002 Aug 27, 2026
45810e3
fix(app): keep pattern types out of the cheat table entirely
JeanExtreme002 Aug 27, 2026
ee36291
feat(app): make the pattern types work as cheat-table entries
JeanExtreme002 Aug 28, 2026
01457c8
fix(app): writing a value must not resize the entry that holds it
JeanExtreme002 Aug 28, 2026
3f8fb98
fix(app): keep the regex type where a value is read at a known address
JeanExtreme002 Aug 28, 2026
908de18
Merge branch 'main' into fix/byte-array-infers-length
JeanExtreme002 Aug 28, 2026
c6d03c2
fix(app): stop a type change from poisoning the value it left behind
JeanExtreme002 Aug 28, 2026
4b27cac
fix(app): re-arm a frozen row, and refuse writes wider than the entry
JeanExtreme002 Aug 28, 2026
81cfedf
fix(app): one width guard, measured in bytes, on every path to an add…
JeanExtreme002 Aug 28, 2026
1215f58
fix(app): let the bulk edit write with the value it is replacing
JeanExtreme002 Aug 28, 2026
92cfa80
fix(app): don't corner the bulk edit, the width field, or a frozen row
JeanExtreme002 Aug 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 114 additions & 12 deletions PyMemoryEditor/app/cheat_table.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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()
Expand DownExpand Up@@ -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)
Expand DownExpand Up@@ -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
Expand DownExpand Up@@ -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
Expand DownExpand Up@@ -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)))
Expand DownExpand Up@@ -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:
Expand All@@ -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
Expand DownExpand Up@@ -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(
Expand DownExpand Up@@ -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,
)
Expand Down
14 changes: 13 additions & 1 deletion PyMemoryEditor/app/pointer_chain_dialog.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand DownExpand Up@@ -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)

Expand Down
16 changes: 13 additions & 3 deletions PyMemoryEditor/app/pointer_scan_dialog.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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__)
Expand DownExpand Up@@ -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)
Expand Down
78 changes: 58 additions & 20 deletions PyMemoryEditor/app/scan_worker.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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


Expand DownExpand Up@@ -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:
Expand All@@ -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``).
"""
Expand All@@ -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),
Expand All@@ -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.
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(app): infer the byte-array scan width from the value entered by JeanExtreme002 · Pull Request #87 · JeanExtreme002/PyMemoryEditor · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
1ad73c9
fix(app): infer the byte-array scan width from the value entered
JeanExtreme002 Aug 27, 2026
164a64e
fix(app): keep the length readout usable for next-scan and type switches
JeanExtreme002 Aug 27, 2026
14e79ef
fix(app): report no length at all until a value-sized scan has a value
JeanExtreme002 Aug 27, 2026
ba64e89
fix(app): refine no-value scans at the width their baseline was recor…
JeanExtreme002 Aug 27, 2026
2e20981
fix(app): size every operation on existing results by the scan that m…
JeanExtreme002 Aug 27, 2026
3343c55
fix(app): confirm the scan baseline on completion, and stop re-derivi…
JeanExtreme002 Aug 27, 2026
0d70f8f
fix(app): floor cheat-entry widths at the table's door
JeanExtreme002 Aug 27, 2026
4de2a7e
fix(app): end the scan cycle cleanly, and reject the deltas that neve…
JeanExtreme002 Aug 27, 2026
286b630
fix(app): promote AOB hits as byte arrays, and tighten the baseline's…
JeanExtreme002 Aug 27, 2026
45810e3
fix(app): keep pattern types out of the cheat table entirely
JeanExtreme002 Aug 27, 2026
ee36291
feat(app): make the pattern types work as cheat-table entries
JeanExtreme002 Aug 28, 2026
01457c8
fix(app): writing a value must not resize the entry that holds it
JeanExtreme002 Aug 28, 2026
3f8fb98
fix(app): keep the regex type where a value is read at a known address
JeanExtreme002 Aug 28, 2026
908de18
Merge branch 'main' into fix/byte-array-infers-length
JeanExtreme002 Aug 28, 2026
c6d03c2
fix(app): stop a type change from poisoning the value it left behind
JeanExtreme002 Aug 28, 2026
4b27cac
fix(app): re-arm a frozen row, and refuse writes wider than the entry
JeanExtreme002 Aug 28, 2026
81cfedf
fix(app): one width guard, measured in bytes, on every path to an add…
JeanExtreme002 Aug 28, 2026
1215f58
fix(app): let the bulk edit write with the value it is replacing
JeanExtreme002 Aug 28, 2026
92cfa80
fix(app): don't corner the bulk edit, the width field, or a frozen row
JeanExtreme002 Aug 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 114 additions & 12 deletions PyMemoryEditor/app/cheat_table.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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()
Expand DownExpand Up@@ -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)
Expand DownExpand Up@@ -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
Expand DownExpand Up@@ -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
Expand DownExpand Up@@ -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)))
Expand DownExpand Up@@ -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:
Expand All@@ -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
Expand DownExpand Up@@ -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(
Expand DownExpand Up@@ -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,
)
Expand Down
14 changes: 13 additions & 1 deletion PyMemoryEditor/app/pointer_chain_dialog.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand DownExpand Up@@ -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)

Expand Down
16 changes: 13 additions & 3 deletions PyMemoryEditor/app/pointer_scan_dialog.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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__)
Expand DownExpand Up@@ -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)
Expand Down
78 changes: 58 additions & 20 deletions PyMemoryEditor/app/scan_worker.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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


Expand DownExpand Up@@ -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:
Expand All@@ -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``).
"""
Expand All@@ -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),
Expand All@@ -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.
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(app): infer the byte-array scan width from the value entered by JeanExtreme002 · Pull Request #87 · JeanExtreme002/PyMemoryEditor · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
1ad73c9
fix(app): infer the byte-array scan width from the value entered
JeanExtreme002 Aug 27, 2026
164a64e
fix(app): keep the length readout usable for next-scan and type switches
JeanExtreme002 Aug 27, 2026
14e79ef
fix(app): report no length at all until a value-sized scan has a value
JeanExtreme002 Aug 27, 2026
ba64e89
fix(app): refine no-value scans at the width their baseline was recor…
JeanExtreme002 Aug 27, 2026
2e20981
fix(app): size every operation on existing results by the scan that m…
JeanExtreme002 Aug 27, 2026
3343c55
fix(app): confirm the scan baseline on completion, and stop re-derivi…
JeanExtreme002 Aug 27, 2026
0d70f8f
fix(app): floor cheat-entry widths at the table's door
JeanExtreme002 Aug 27, 2026
4de2a7e
fix(app): end the scan cycle cleanly, and reject the deltas that neve…
JeanExtreme002 Aug 27, 2026
286b630
fix(app): promote AOB hits as byte arrays, and tighten the baseline's…
JeanExtreme002 Aug 27, 2026
45810e3
fix(app): keep pattern types out of the cheat table entirely
JeanExtreme002 Aug 27, 2026
ee36291
feat(app): make the pattern types work as cheat-table entries
JeanExtreme002 Aug 28, 2026
01457c8
fix(app): writing a value must not resize the entry that holds it
JeanExtreme002 Aug 28, 2026
3f8fb98
fix(app): keep the regex type where a value is read at a known address
JeanExtreme002 Aug 28, 2026
908de18
Merge branch 'main' into fix/byte-array-infers-length
JeanExtreme002 Aug 28, 2026
c6d03c2
fix(app): stop a type change from poisoning the value it left behind
JeanExtreme002 Aug 28, 2026
4b27cac
fix(app): re-arm a frozen row, and refuse writes wider than the entry
JeanExtreme002 Aug 28, 2026
81cfedf
fix(app): one width guard, measured in bytes, on every path to an add…
JeanExtreme002 Aug 28, 2026
1215f58
fix(app): let the bulk edit write with the value it is replacing
JeanExtreme002 Aug 28, 2026
92cfa80
fix(app): don't corner the bulk edit, the width field, or a frozen row
JeanExtreme002 Aug 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 114 additions & 12 deletions PyMemoryEditor/app/cheat_table.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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()
Expand DownExpand Up@@ -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)
Expand DownExpand Up@@ -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
Expand DownExpand Up@@ -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
Expand DownExpand Up@@ -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)))
Expand DownExpand Up@@ -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:
Expand All@@ -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
Expand DownExpand Up@@ -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(
Expand DownExpand Up@@ -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,
)
Expand Down
14 changes: 13 additions & 1 deletion PyMemoryEditor/app/pointer_chain_dialog.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand DownExpand Up@@ -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)

Expand Down
16 changes: 13 additions & 3 deletions PyMemoryEditor/app/pointer_scan_dialog.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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__)
Expand DownExpand Up@@ -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)
Expand Down
78 changes: 58 additions & 20 deletions PyMemoryEditor/app/scan_worker.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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


Expand DownExpand Up@@ -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:
Expand All@@ -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``).
"""
Expand All@@ -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),
Expand All@@ -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.
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' fix(app): infer the byte-array scan width from the value entered by JeanExtreme002 · Pull Request #87 · JeanExtreme002/PyMemoryEditor · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
1ad73c9
fix(app): infer the byte-array scan width from the value entered
JeanExtreme002 Aug 27, 2026
164a64e
fix(app): keep the length readout usable for next-scan and type switches
JeanExtreme002 Aug 27, 2026
14e79ef
fix(app): report no length at all until a value-sized scan has a value
JeanExtreme002 Aug 27, 2026
ba64e89
fix(app): refine no-value scans at the width their baseline was recor…
JeanExtreme002 Aug 27, 2026
2e20981
fix(app): size every operation on existing results by the scan that m…
JeanExtreme002 Aug 27, 2026
3343c55
fix(app): confirm the scan baseline on completion, and stop re-derivi…
JeanExtreme002 Aug 27, 2026
0d70f8f
fix(app): floor cheat-entry widths at the table's door
JeanExtreme002 Aug 27, 2026
4de2a7e
fix(app): end the scan cycle cleanly, and reject the deltas that neve…
JeanExtreme002 Aug 27, 2026
286b630
fix(app): promote AOB hits as byte arrays, and tighten the baseline's…
JeanExtreme002 Aug 27, 2026
45810e3
fix(app): keep pattern types out of the cheat table entirely
JeanExtreme002 Aug 27, 2026
ee36291
feat(app): make the pattern types work as cheat-table entries
JeanExtreme002 Aug 28, 2026
01457c8
fix(app): writing a value must not resize the entry that holds it
JeanExtreme002 Aug 28, 2026
3f8fb98
fix(app): keep the regex type where a value is read at a known address
JeanExtreme002 Aug 28, 2026
908de18
Merge branch 'main' into fix/byte-array-infers-length
JeanExtreme002 Aug 28, 2026
c6d03c2
fix(app): stop a type change from poisoning the value it left behind
JeanExtreme002 Aug 28, 2026
4b27cac
fix(app): re-arm a frozen row, and refuse writes wider than the entry
JeanExtreme002 Aug 28, 2026
81cfedf
fix(app): one width guard, measured in bytes, on every path to an add…
JeanExtreme002 Aug 28, 2026
1215f58
fix(app): let the bulk edit write with the value it is replacing
JeanExtreme002 Aug 28, 2026
92cfa80
fix(app): don't corner the bulk edit, the width field, or a frozen row
JeanExtreme002 Aug 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 114 additions & 12 deletions PyMemoryEditor/app/cheat_table.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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()
Expand DownExpand Up@@ -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)
Expand DownExpand Up@@ -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
Expand DownExpand Up@@ -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
Expand DownExpand Up@@ -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)))
Expand DownExpand Up@@ -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:
Expand All@@ -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
Expand DownExpand Up@@ -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(
Expand DownExpand Up@@ -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,
)
Expand Down
14 changes: 13 additions & 1 deletion PyMemoryEditor/app/pointer_chain_dialog.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand DownExpand Up@@ -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)

Expand Down
16 changes: 13 additions & 3 deletions PyMemoryEditor/app/pointer_scan_dialog.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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__)
Expand DownExpand Up@@ -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)
Expand Down
78 changes: 58 additions & 20 deletions PyMemoryEditor/app/scan_worker.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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


Expand DownExpand Up@@ -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:
Expand All@@ -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``).
"""
Expand All@@ -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),
Expand All@@ -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.
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(app): infer the byte-array scan width from the value entered by JeanExtreme002 · Pull Request #87 · JeanExtreme002/PyMemoryEditor · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
1ad73c9
fix(app): infer the byte-array scan width from the value entered
JeanExtreme002 Aug 27, 2026
164a64e
fix(app): keep the length readout usable for next-scan and type switches
JeanExtreme002 Aug 27, 2026
14e79ef
fix(app): report no length at all until a value-sized scan has a value
JeanExtreme002 Aug 27, 2026
ba64e89
fix(app): refine no-value scans at the width their baseline was recor…
JeanExtreme002 Aug 27, 2026
2e20981
fix(app): size every operation on existing results by the scan that m…
JeanExtreme002 Aug 27, 2026
3343c55
fix(app): confirm the scan baseline on completion, and stop re-derivi…
JeanExtreme002 Aug 27, 2026
0d70f8f
fix(app): floor cheat-entry widths at the table's door
JeanExtreme002 Aug 27, 2026
4de2a7e
fix(app): end the scan cycle cleanly, and reject the deltas that neve…
JeanExtreme002 Aug 27, 2026
286b630
fix(app): promote AOB hits as byte arrays, and tighten the baseline's…
JeanExtreme002 Aug 27, 2026
45810e3
fix(app): keep pattern types out of the cheat table entirely
JeanExtreme002 Aug 27, 2026
ee36291
feat(app): make the pattern types work as cheat-table entries
JeanExtreme002 Aug 28, 2026
01457c8
fix(app): writing a value must not resize the entry that holds it
JeanExtreme002 Aug 28, 2026
3f8fb98
fix(app): keep the regex type where a value is read at a known address
JeanExtreme002 Aug 28, 2026
908de18
Merge branch 'main' into fix/byte-array-infers-length
JeanExtreme002 Aug 28, 2026
c6d03c2
fix(app): stop a type change from poisoning the value it left behind
JeanExtreme002 Aug 28, 2026
4b27cac
fix(app): re-arm a frozen row, and refuse writes wider than the entry
JeanExtreme002 Aug 28, 2026
81cfedf
fix(app): one width guard, measured in bytes, on every path to an add…
JeanExtreme002 Aug 28, 2026
1215f58
fix(app): let the bulk edit write with the value it is replacing
JeanExtreme002 Aug 28, 2026
92cfa80
fix(app): don't corner the bulk edit, the width field, or a frozen row
JeanExtreme002 Aug 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 114 additions & 12 deletions PyMemoryEditor/app/cheat_table.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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()
Expand DownExpand Up@@ -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)
Expand DownExpand Up@@ -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
Expand DownExpand Up@@ -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
Expand DownExpand Up@@ -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)))
Expand DownExpand Up@@ -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:
Expand All@@ -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
Expand DownExpand Up@@ -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(
Expand DownExpand Up@@ -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,
)
Expand Down
14 changes: 13 additions & 1 deletion PyMemoryEditor/app/pointer_chain_dialog.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand DownExpand Up@@ -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)

Expand Down
16 changes: 13 additions & 3 deletions PyMemoryEditor/app/pointer_scan_dialog.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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__)
Expand DownExpand Up@@ -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)
Expand Down
78 changes: 58 additions & 20 deletions PyMemoryEditor/app/scan_worker.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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


Expand DownExpand Up@@ -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:
Expand All@@ -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``).
"""
Expand All@@ -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),
Expand All@@ -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.
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(app): infer the byte-array scan width from the value entered by JeanExtreme002 · Pull Request #87 · JeanExtreme002/PyMemoryEditor · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
1ad73c9
fix(app): infer the byte-array scan width from the value entered
JeanExtreme002 Aug 27, 2026
164a64e
fix(app): keep the length readout usable for next-scan and type switches
JeanExtreme002 Aug 27, 2026
14e79ef
fix(app): report no length at all until a value-sized scan has a value
JeanExtreme002 Aug 27, 2026
ba64e89
fix(app): refine no-value scans at the width their baseline was recor…
JeanExtreme002 Aug 27, 2026
2e20981
fix(app): size every operation on existing results by the scan that m…
JeanExtreme002 Aug 27, 2026
3343c55
fix(app): confirm the scan baseline on completion, and stop re-derivi…
JeanExtreme002 Aug 27, 2026
0d70f8f
fix(app): floor cheat-entry widths at the table's door
JeanExtreme002 Aug 27, 2026
4de2a7e
fix(app): end the scan cycle cleanly, and reject the deltas that neve…
JeanExtreme002 Aug 27, 2026
286b630
fix(app): promote AOB hits as byte arrays, and tighten the baseline's…
JeanExtreme002 Aug 27, 2026
45810e3
fix(app): keep pattern types out of the cheat table entirely
JeanExtreme002 Aug 27, 2026
ee36291
feat(app): make the pattern types work as cheat-table entries
JeanExtreme002 Aug 28, 2026
01457c8
fix(app): writing a value must not resize the entry that holds it
JeanExtreme002 Aug 28, 2026
3f8fb98
fix(app): keep the regex type where a value is read at a known address
JeanExtreme002 Aug 28, 2026
908de18
Merge branch 'main' into fix/byte-array-infers-length
JeanExtreme002 Aug 28, 2026
c6d03c2
fix(app): stop a type change from poisoning the value it left behind
JeanExtreme002 Aug 28, 2026
4b27cac
fix(app): re-arm a frozen row, and refuse writes wider than the entry
JeanExtreme002 Aug 28, 2026
81cfedf
fix(app): one width guard, measured in bytes, on every path to an add…
JeanExtreme002 Aug 28, 2026
1215f58
fix(app): let the bulk edit write with the value it is replacing
JeanExtreme002 Aug 28, 2026
92cfa80
fix(app): don't corner the bulk edit, the width field, or a frozen row
JeanExtreme002 Aug 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 114 additions & 12 deletions PyMemoryEditor/app/cheat_table.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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()
Expand DownExpand Up@@ -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)
Expand DownExpand Up@@ -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
Expand DownExpand Up@@ -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
Expand DownExpand Up@@ -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)))
Expand DownExpand Up@@ -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:
Expand All@@ -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
Expand DownExpand Up@@ -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(
Expand DownExpand Up@@ -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,
)
Expand Down
14 changes: 13 additions & 1 deletion PyMemoryEditor/app/pointer_chain_dialog.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand DownExpand Up@@ -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)

Expand Down
16 changes: 13 additions & 3 deletions PyMemoryEditor/app/pointer_scan_dialog.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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__)
Expand DownExpand Up@@ -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)
Expand Down
78 changes: 58 additions & 20 deletions PyMemoryEditor/app/scan_worker.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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


Expand DownExpand Up@@ -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:
Expand All@@ -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``).
"""
Expand All@@ -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),
Expand All@@ -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.
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); fix(app): infer the byte-array scan width from the value entered by JeanExtreme002 · Pull Request #87 · JeanExtreme002/PyMemoryEditor · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
1ad73c9
fix(app): infer the byte-array scan width from the value entered
JeanExtreme002 Aug 27, 2026
164a64e
fix(app): keep the length readout usable for next-scan and type switches
JeanExtreme002 Aug 27, 2026
14e79ef
fix(app): report no length at all until a value-sized scan has a value
JeanExtreme002 Aug 27, 2026
ba64e89
fix(app): refine no-value scans at the width their baseline was recor…
JeanExtreme002 Aug 27, 2026
2e20981
fix(app): size every operation on existing results by the scan that m…
JeanExtreme002 Aug 27, 2026
3343c55
fix(app): confirm the scan baseline on completion, and stop re-derivi…
JeanExtreme002 Aug 27, 2026
0d70f8f
fix(app): floor cheat-entry widths at the table's door
JeanExtreme002 Aug 27, 2026
4de2a7e
fix(app): end the scan cycle cleanly, and reject the deltas that neve…
JeanExtreme002 Aug 27, 2026
286b630
fix(app): promote AOB hits as byte arrays, and tighten the baseline's…
JeanExtreme002 Aug 27, 2026
45810e3
fix(app): keep pattern types out of the cheat table entirely
JeanExtreme002 Aug 27, 2026
ee36291
feat(app): make the pattern types work as cheat-table entries
JeanExtreme002 Aug 28, 2026
01457c8
fix(app): writing a value must not resize the entry that holds it
JeanExtreme002 Aug 28, 2026
3f8fb98
fix(app): keep the regex type where a value is read at a known address
JeanExtreme002 Aug 28, 2026
908de18
Merge branch 'main' into fix/byte-array-infers-length
JeanExtreme002 Aug 28, 2026
c6d03c2
fix(app): stop a type change from poisoning the value it left behind
JeanExtreme002 Aug 28, 2026
4b27cac
fix(app): re-arm a frozen row, and refuse writes wider than the entry
JeanExtreme002 Aug 28, 2026
81cfedf
fix(app): one width guard, measured in bytes, on every path to an add…
JeanExtreme002 Aug 28, 2026
1215f58
fix(app): let the bulk edit write with the value it is replacing
JeanExtreme002 Aug 28, 2026
92cfa80
fix(app): don't corner the bulk edit, the width field, or a frozen row
JeanExtreme002 Aug 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 114 additions & 12 deletions PyMemoryEditor/app/cheat_table.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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()
Expand DownExpand Up@@ -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)
Expand DownExpand Up@@ -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
Expand DownExpand Up@@ -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
Expand DownExpand Up@@ -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)))
Expand DownExpand Up@@ -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:
Expand All@@ -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
Expand DownExpand Up@@ -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(
Expand DownExpand Up@@ -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,
)
Expand Down
14 changes: 13 additions & 1 deletion PyMemoryEditor/app/pointer_chain_dialog.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand DownExpand Up@@ -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)

Expand Down
16 changes: 13 additions & 3 deletions PyMemoryEditor/app/pointer_scan_dialog.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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__)
Expand DownExpand Up@@ -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)
Expand Down
78 changes: 58 additions & 20 deletions PyMemoryEditor/app/scan_worker.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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


Expand DownExpand Up@@ -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:
Expand All@@ -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``).
"""
Expand All@@ -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),
Expand All@@ -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.
Expand Down
Loading
Loading