Skip to content

fix(app): infer the byte-array scan width from the value entered - #87

Merged
JeanExtreme002 merged 19 commits into
mainfrom
fix/byte-array-infers-length
Aug 28, 2026
Merged

fix(app): infer the byte-array scan width from the value entered#87
JeanExtreme002 merged 19 commits into
mainfrom
fix/byte-array-infers-length

Conversation

@JeanExtreme002

@JeanExtreme002JeanExtreme002 commented Aug 27, 2026

Copy link
Copy Markdown
Owner

Why is this PR necessary, what does it do?

Fixes#79, and the run of related width bugs that surfaced while doing it.

The reported bug

Scanning for a Byte Array (Hex) required the user to keep the Length field
in sync by hand with the bytes they typed, and got both failure modes wrong:

  • a value wider than Length was squeezed into a fixed-width ctypes buffer
    and the scan died with ValueError: byte string too long. The default Length
    is 4, so any 5-byte value hit this — exactly what Byte array search shouldn't need to specify length #79 reports;
  • a value narrower than Length was NUL-padded, silently turning the scan
    into "these bytes followed by zeros": no error, just a scan that finds
    nothing and never says why.

String (UTF-8) already derived its width from the typed text, and the library
itself infers it in resolve_bufflength_for_value — the scanner panel was the
only place still asking the user to count. Byte Array now follows the same rule,
and the Length field became a read-only readout of what the value sizes to.
Partial matching keeps its own value type (AOB Pattern (IDA)).

One width, one source

Fixing that exposed the same mistake in every other place that needed "the width
of the rows currently on screen" and read the Length field instead — which
follows the Value box, and the user can retype that between scans:

  • promote to cheat table created entries at the wrong width (a 4-byte olá
    scan promoted at the spec's 16, so the cell pulled 12 bytes of neighbouring
    memory in on every poll tick);
  • the no-value comparisons (Changed / Unchanged / Increased / Decreased)
    re-read at a width the baseline was never recorded at, so every address
    reported as changed;
  • Update Values re-read at whatever was in the Value box, overwriting every
    stored value with a truncated read — and aborted outright with
    "Invalid value" if that box happened to be empty;
  • the readout ignored a range's upper bound while the scan sized with
    max(lo, hi).

They all take it from _last_scan_length now — the width of the scan that
produced the rows — which is adopted when a scan lands, not when it is
dispatched, so one that errors, is cancelled, or is rejected by its handler
can't leave a width behind for a later refresh to pick up. is_sized_by_value()
replaced the "String or Byte Array, but not a pattern" test that was being
spelled out by hand at each site, which is what kept producing these.

Pattern types now round-trip as cheat entries

Giving AOB entries a real width made their cheat-table cell editable for the
first time, and it was wired backwards: the cell formats what it reads as hex
but parsed an edit as a pattern, so typing 00 wrote ASCII 0x30 0x30 into
the target and reported success.

The cause was the spec answering one question. spec.parse answers the
scanner's — "what am I searching for?" — and for an IDA pattern that is the
pattern text, which is not a value any address can hold. A cheat entry is
read and written every poll tick, so its type has to round-trip:
format(bytes read) must parse back to the same bytes.

The specs answer both questions now. ValueTypeSpec.parse_write turns cell text
into the bytes to write and receives the value last read at the address;
parse_value_for_write picks it when a spec has one. The scanner keeps asking
parse_value — it only ever searches — and the cheat table only ever writes, so
it no longer imports parse_value at all.

For an IDA pattern the tokens resolve to the bytes they name, and ? keeps the
byte already at that offset:

cell shows 48 8B 12 34 00
edit "48 8B 90 90 00" → writes 48 8B 90 90 00
edit "48 8B ? ? 90" → writes 48 8B 12 34 90 (the ?s are preserved)

That mirrors the wildcard's search meaning and is what makes a signature
patchable — you name the bytes you mean to change and leave the operands alone,
the same semantics Cheat Engine uses. A wildcard with nothing read yet is
refused with a message rather than writing a zero. A regex names a set of byte
strings rather than one, so there the cell text is taken literally, matching what
the cell displays. tokenize_pattern comes out of compile_pattern so the
search and write paths share one set of token rules and error messages.

Writing a value must not resize the entry that holds it, either. parse_value
honours an explicit length override, so before this no value edit ever changed
an entry's width; the new write path returned len(value) unconditionally, and
Regex does accept an override, so bulk-editing a 64-byte regex entry to "hi"
collapsed it to 2 bytes and the row displayed 2 bytes of the address from then
on. The write path follows the same override rule as the search path now.

Neighbouring, same cause: every path that fell back to the AOB spec's declared
length of 0 — promotion, adding an address by hand, changing an entry's type,
importing a JSON table — minted a zero-width entry that read back empty on every
tick. add_entry floors the width, since it is the one door all of those pass
through.

The pointer dialogs keep the regex type

Both dialogs read a value at an address the user already has, and both filtered
their "Read value as" list by is_pattern — which lumps together two specs that
behave nothing alike once the address is known.

An IDA pattern genuinely can't be offered there: it declares a length of 0 (the
scanner derives a match's width from the pattern) and refuses a length
override, so the dialog read zero bytes and displayed nothing. A regex has
neither problem — its width comes from the Length field, and it renders bytes as
text up to the first NUL, which String (UTF-8) does not:

memory: b"Player42\0\xff\xfe\x01rest"
Regex (String) → "Player42"
String (UTF-8) → "Player42\0\ufffd\ufffd\x01rest"

So it isn't a duplicate of anything else in the list. has_readable_width()
replaces the is_pattern test and asks what actually matters — can this spec
size a read at an address I already have? — derived from the spec rather than
from a label. Pointer Scan had the old filter before this branch and Pointer
Chain gained it during it; both were dropping the regex for the same wrong
reason, so both are corrected. The cheat table keeps offering both types, since
an entry carries its own width.

Comparisons that never worked

Increased/Decreased Value By on String / Byte Array: the comparator does
prev + exp, which concatenates rather than adds and is never equal to the
fixed-width value read back, while prev - exp raises TypeError that the
refine worker swallows into "doesn't match". Every address was dropped in
silence. The combination is rejected with a message pointing at Changed /
Unchanged Value. An empty String value likewise sized to a 1-byte NUL
buffer, so scanning for it matched every zeroed byte in the target; Byte Array
already rejected its own empty input.

Checklist (complete all items):

  • Added tests as necessary.
  • There is no breaking change for existing features.

References:

Closes#79

Notes:

Reproduction of the reported bug, and the same call after the fix:

build_scan_request(BYTES, EXACT_VALUE, value_text='00 11 22 AA BB CC', length_spin_value=4)
# before → length=4 → value_to_bytes(...) → ValueError: byte string too long# after → length=6 → value_to_bytes(...) → b'\x00\x11"\xaa\xbb\xcc'

Deliberately left alone: for VALUE_BETWEEN with endpoints of different widths
the shorter one is still NUL-padded up to the wider. That is documented library
behaviour (resolve_bufflength_for_value), it is what makes a fixed-width
comparison possible when the user gives two different widths, and str has
always behaved the same way.

Suite goes from 486 to 520 passing with no new failures; make lint is clean and
make type-check reports the same pre-existing errors as main (none in the
files touched). The 15 still-failing tests are unrelated to this branch: they are the macOS
KERN_MEMORY_ERROR scan abort fixed by #88, and they fail the same way on
main. With both branches merged locally the whole suite passes — 538 tests,
no failures — which is the first time the real memory read/write path has been
exercised here at all.

Scanning for a Byte Array (Hex) required the user to keep the Length
field in sync with the bytes they typed, and got both failure modes
wrong when they didn't:
* a value wider than Length was squeezed into a fixed-width ctypes
buffer and the scan died with "ValueError: byte string too long"
(the default Length is 4, so any 5-byte value hit this);
* a value narrower than Length was NUL-padded, silently turning the
scan into "these bytes followed by zeros" with no feedback.
String (UTF-8) already derived its width from the typed text, and the
library itself infers it in resolve_bufflength_for_value — the scanner
panel was the only place still asking. Byte Array now follows the same
rule: the Length field becomes a read-only readout of the parsed byte
count, and build_scan_request lets parse_value size the buffer. Partial
matching keeps its own value type (AOB Pattern).
This also fixes promoting byte-array results to the cheat table, which
read the same spin box and so created entries truncated to 4 bytes.
The no-value comparisons (Increased / Changed / …) still read the
Length field: they carry no value to measure, and the readout holds the
width the previous scan settled on.
Closes#79
@github-actionsgithub-actionsBot added app GUI application changes (PyMemoryEditor/app/) lib Library changes (PyMemoryEditor/) tests Test changes (tests/) labels Aug 27, 2026
Two problems in the same neighbourhood, surfaced by review of the
byte-array fix.
The no-value comparisons (Increased / Changed / …) carry no value to
measure, so they read the Length field — but str was excluded and fell
back to the spec default of 16. Picking "Changed Value" after a 4-byte
"olá" scan refined it by re-reading 16 bytes per address, so the value
read back ("olá\0\0…") never equalled the "olá" the first scan had
recorded and every address reported as Changed. The readout now holds
its last valid width when the value field is cleared (it already did
for byte arrays), and the no-value branch honours it for both types.
Promoting such a row to the cheat table was hitting the same 1-byte
readout and is fixed with it.
Switching value type left the readout showing a width computed for the
previous type when the value doesn't parse as the new one — 21 bytes of
string carried over into Byte Array, for instance. That field is
read-only now, so the user had no way to correct it. Selecting a
value-sized type seeds the spec default before syncing.
Follow-up to #79.
Opening the app lands on Int32, whose Length reads 4. Picking Byte
Array (Hex) kept showing that 4 — it was never a width the byte-array
scan would use — and the first hex digit typed dropped it to 1, so the
one number the field ever showed on its own was wrong.
String and Byte Array take their width from the value, and the field is
read-only for them, so there is nothing to show and no way for the user
to correct whatever we invent. The spin now opens a 0 slot rendered as
"— (set by the value)" for that state, and fills in a real number as
soon as the value sizes to one. The two consumers that could see the 0
(the no-value next-scan width and the promote-to-cheat-table length)
fall back to the spec default.
Selecting any other type clears the special-value text and restores the
minimum of 1, so a 1-byte Int8 shows "1 bytes" rather than landing on
the empty-slot rendering.
Follow-up to #79.
…ded with
Increased / Decreased / Changed / Unchanged compare against the values
the previous scan recorded, so they have to re-read at that scan's
width. They were reading the Length field instead, which tracks the
value currently in the Value box — and the user can edit that between
scans without rescanning. First Scan for the byte array `00 11`, type
`00 11 22 33`, pick "Changed Value", Next Scan: every address is
re-read 4 bytes wide, compared against a 2-byte baseline, and reported
as Changed. "Update Values" was worse, writing the mismatched reads
back over the baseline for every later comparison.
The panel now records the width of each scan it emits and passes it as
previous_scan_length; it's dropped along with the results it describes.
The Length readout no longer has to double as that memory, so it goes
back to being exactly what the current value sizes to — which also
fixes it claiming "4 bytes" for a String whose value box had been
cleared while the request was built at length 1.
Two neighbouring bugs on the same paths:
* promoting an AOB hit to the cheat table created a zero-width entry
(no Length field, and the IDA spec's length is 0), re-read as empty on
every poll tick. It now measures the pattern — one token, one byte;
* an empty String value parsed to a 1-byte NUL buffer, so scanning it
matched every zeroed byte in the target. Byte Array already rejected
its own empty input; both now do.
Follow-up to #79.
…ade them
Third round of the same mistake, in the paths the previous fix didn't
reach. All four take the width from the Length readout, which follows
the Value box — a no-value scan type clears that box outright, and the
user can retype it between scans without rescanning:
* promoting to the cheat table fell through to the spec default: a
4-byte "olá" scan promoted at 16, so the entry pulled 12 bytes of
neighbouring memory into the cell on every poll tick;
* "Increased/Decreased value BY" sit outside NO_VALUE_SCAN_TYPES, so
they missed the previous fix and sized the re-read from the *delta*
text — a 4-byte baseline re-read 1 byte at a time, dropping every
address, and then written back as the new baseline width;
* "Update Values" is a read-only refresh, but re-read at whatever was
in the Value box and moved the baseline to match, overwriting every
stored value with a truncated read;
* the readout ignored the range upper bound while the scan sized with
max(lo, hi), so a "AA".."AA BB CC" range reported 1 byte and ran at 3.
All four now use the width the results were scanned at. _sync_value_length
reads both bounds and no longer needs its callers to check the type.
Also: re-picking "AOB Pattern (IDA)" on a promoted hit reset the entry
to a zero-width buffer (the spec's own length is 0), undoing the pattern
width from the other side; and the empty-value message is neutral now
that it surfaces in the cheat table's write dialogs too.
Follow-up to #79.
…ng it
Fourth round on the same class, so this one names the concept instead of
patching another site: is_sized_by_value(spec) is now a single predicate
the panel uses everywhere it previously spelled out "String or Byte
Array, but not a pattern" by hand — which is what kept producing these.
That gate was wrong in one place already: accepts_length_override is
True for Regex too, so the width override caught it and made its Length
field — the only editable one, and the user's own byte_length — inert
after a first scan. Update Values and promote honour it again.
Update Values was building a full request just to throw the value away
(RefineScanWorker applies no comparison when filter_only is False). With
the new empty-value rejection that turned into a hard regression: a
Value box cleared by a no-value scan type aborted the refresh with
"Invalid value" instead of refreshing. It builds the request directly
now and can't fail.
And the baseline width is adopted when a scan *lands*, not when it is
dispatched: set_has_results is called by the owner on completion, so a
Next Scan whose worker errors out no longer leaves a width the values on
screen were never read at — which the following Changed/Unchanged refine
would have compared against.
Also: bulk edit collapsed AOB entries to a zero-width buffer exactly as
_change_type did before the last commit, and manually adding an AOB
address created one from the start; both ask for or keep a real width.
Follow-up to #79.
Chasing the zero-width AOB entry one call site at a time found two more:
importing a cheat table whose rows were saved by a build that promoted
AOB hits at the spec's 0 (`raw.get("length") or spec.length` is `0 or 0`),
and the pointer-chain dialog, which — unlike the pointer-scan dialog
right next to it — offered the pattern types in its "Read value as"
combo, so `_current_spec()` handed back the spec's 0.
Rather than a fourth guard, the floor now sits in add_entry: every entry
enters the table through it, whichever way it was made — promoted from a
scan, from either pointer dialog, added by hand, or loaded from JSON.
A zero-width entry can't be spotted from the table (the Value column
just reads back empty forever), so this is worth enforcing once at the
boundary rather than trusting each producer.
The pointer-chain dialog also gets the filter its sibling already had,
with the same reasoning: reading a value "as a pattern" is meaningless
when the address is already known.
Follow-up to #79.
…r worked
Last round's baseline fix promoted a pending width on any report of
results, and I reasoned that only a completed scan could produce one.
That was wrong: "Update Values" finishes through the same handler. So a
Next Scan whose worker errored left its width behind, and the next plain
refresh adopted it — 2-byte values on screen, baseline claiming 4, and
the following Changed Value refine drops everything. The pending width is
now discarded when the scan cycle ends (set_busy(False), which the owner
emits after the completion signal), so only the scan that actually landed
can have set it.
"Increased/Decreased Value By" never worked on String / Byte Array: the
comparator does `prev + exp`, which concatenates rather than adds and is
never equal to the fixed-width value read back, while `prev - exp` raises
TypeError that the refine worker swallows into "doesn't match". Every
address was dropped and nothing said why. The combination is rejected
with a message pointing at Changed / Unchanged Value, and last round's
width branch for it goes away — it was sizing a comparison that could
never be true.
Also: the cheat table's "Change buffer length" dialog capped at 1024
while a promoted String / Byte Array entry can be as wide as the value
scanned for, so opening it and pressing OK silently shrank the entry.
Follow-up to #79.
… edges
Giving AOB entries a real width made their Value cell live for the first
time, and it was wired backwards: the cell formats the bytes it reads as
hex, but parses an edit as a *pattern*, so typing "00" wrote ASCII
0x30 0x30 rather than the byte 0x00 — reported as a successful write. A
pattern finds an address; it can't hold a value. Hits promote as Byte
Array at the pattern's width now, so the cell reads and writes the same
thing. The zero-width buffer used to make every such write fail, which
is why this never showed.
Two more edges on the pending-width adoption:
* a cancelled refine still reports results (the worker breaks out of its
loop and emits finished_ok with what it kept), but only the rows it
reached were re-read at the new width. Neither width describes the
table, so cancelling keeps the one most rows were read at;
* a request the owner rejects before the busy cycle begins (its handler
returns early) never reaches set_busy(False), so its width lingered.
"Update Values" now records the width it re-reads at — which is what
the table will hold once it lands, and overwrites anything stale.
Also: the Length field's enable expression still tested a clause that
can no longer be true, reading as if more types than Regex could make it
editable. Regex is the only spec whose width is genuinely the user's.
Follow-up to #79.
Self-review of the whole diff, on the final state rather than commit by
commit. Three things it turned up.
The write-ASCII bug wasn't fixed, only patched at one of its three
doors. Substituting Byte Array inside current_spec_and_length covered
promotion, but adding an address by hand with the AOB type, changing an
existing entry's type to AOB, and importing a JSON table written before
that change all still produced a pattern-typed entry — whose cell
displays hex and writes the ASCII spelling of what you type. A pattern
is search syntax: a way to find an address, never a shape a value is
read and written back as. The type pickers now offer HOLDABLE_TYPE_LABELS
(the non-pattern specs, the same filter the pointer dialogs already use)
and add_entry re-types anything that still arrives — it is the one door
promote, manual add and JSON import all pass through. The substitution
comes out of current_spec_and_length, which was also handing the wrong
spec to "Update Values"; three now-unreachable `or`-guards for the AOB
zero come out with it, since they implied a zero could still arrive.
The delta rejection sat before the pattern short-circuit. Both pattern
specs are bytes-typed, so an AOB scan with a delta type raised instead
of forcing EXACT as the pattern path documents — and pointed the user at
Changed/Unchanged Value, which are disabled in pattern mode. Moved
below, where it means what it says.
Also: "Update Values" carried two comments contradicting each other
about whether it moves the baseline (it does, to the width it re-reads
at), and the Length field's enable rule had two overlapping rationales
left over from when the expression was more than `is_regex`.
Follow-up to #79.
Reverts removing AOB / Regex from the cheat table in favour of fixing
what was actually broken about them.
A cheat entry is read and written every poll tick, so its type has to
round-trip: format(bytes read) must parse back to the same bytes. The
pattern specs failed that because `parse` answers a different question —
"what am I searching for?" — and for an IDA pattern it hands back the
pattern *text*, so a cell displaying `48 8B 00` wrote the ASCII spelling
of that hex.
The specs now answer both questions. `ValueTypeSpec.parse_write` turns
cell text into the bytes to write, and takes the value last read at the
address; `parse_value_for_write` picks it when a spec has one. The
scanner keeps asking `parse_value` — it only ever searches — and the
cheat table only ever writes, so it no longer imports `parse_value` at
all.
For an IDA pattern that means the tokens resolve to the bytes they name,
and `?` keeps the byte already at that offset: `48 8B ? ? 90` over
`48 8B 12 34 00` writes `48 8B 12 34 90`. That mirrors the wildcard's
search meaning and is what makes a signature patchable — you name the
bytes you mean to change and leave the operands alone. A wildcard with
nothing read yet is refused with a message rather than writing a zero.
For a regex, which names a set of byte strings rather than one, the cell
text is taken literally, matching what the cell displays.
`tokenize_pattern` comes out of `compile_pattern` so the search and write
paths share one set of token rules and error messages.
Follow-up to #79.
Self-review of the parse_write commit. The cheat table's bulk edit
stores the width parse_value_for_write returns back onto the entry, and
that width is how many bytes the row *reads* on every poll tick — set by
the user, and none of a write's business.
parse_value honours an explicit length_override for the types that
accept one, so before parse_write existed no value edit ever resized an
entry. The new path returned len(value) unconditionally, and Regex does
accept an override: bulk-editing a 64-byte regex entry to "hi" collapsed
it to 2 bytes, so the row then displayed 2 bytes of the address forever.
It now follows the same override rule as parse_value.
An IDA pattern is unaffected either way — it declines a length override,
so the cheat table never writes the returned width onto the entry — but
it now reports the pattern's own byte count rather than the search
path's 0, which is not a size any buffer could have.
Both pointer dialogs filtered their "Read value as" list by is_pattern,
which lumps together two specs that behave nothing alike once the
address is already known.
An IDA pattern genuinely can't be offered: it declares a length of 0 —
the scanner derives a match's width from the pattern — and refuses a
length override, so the dialog read zero bytes and displayed nothing. A
regex has neither problem: its width comes from the Length field, and it
renders the bytes as text up to the first NUL, which "String (UTF-8)"
does not. Reading b"Player42\0\xff\xfe\x01rest" shows "Player42" as a
regex and "Player42\0��\x01rest" as a string, so it isn't a
duplicate of anything else in the list.
has_readable_width() replaces the is_pattern test and asks the question
that actually matters — can this spec size a read at an address I
already have? — derived from the spec rather than from a label. The
cheat table keeps offering both, since an entry carries its own width.
Pointer Scan had this filter before the branch and Pointer Chain gained
it earlier in it; both were dropping the regex for the same wrong
reason, so both are corrected.
An entry's last_value / frozen_value hold whatever the *previous* spec
decoded, and nothing waits for a fresh poll tick after the type changes,
so the new spec was being applied to the old type's value. Two ways out:
* _rebuild formats it — _fmt_bytes(1234) raises TypeError from inside a
Qt slot, so an Int32 row retyped to Byte Array or AOB left the table
un-rebuilt; a frozen entry would also republish the old value to the
poll worker under the new pytype;
* the bulk edit's "set type" and "set value" run in the same pass, so an
IDA pattern's wildcard reached len() on an int. That raised TypeError
too, which neither call site's `except ValueError` catches.
Both branches now forget the cached value when the label actually
changes — the next tick refills it — and the pattern write path treats a
non-bytes `current` as "nothing read yet", so it stays inside the
ValueError contract its callers handle.
Cancelling a *first* scan no longer discards its width. Both workers
report partial results after a cancel, but only a refine leaves the rows
at mixed widths; every address a first scan found, it found at its own
width, and there is no earlier one to fall back on — so the next
Changed/Unchanged refine went to the spec default, 16 for a String
scanned at 4, which is the failure this branch exists to fix.
_has_results tells the two apart.
Also: an IDA pattern wider than the entry it is typed into was truncated
by prepare_write without a word, while the same edit one byte short was
refused by the wildcard check; it is refused now too. And
current_spec_and_length still documented a re-typing that was reverted
several commits ago.
tokenize_pattern comes back out of util.pattern.__all__ — it exists so
the scan and write paths share token rules, nothing outside the package
consumes it, and neither the guide nor the API reference describes it.
Follow-ups from review of the previous commit, three of them consequences
of it.
Dropping frozen_value on a type change left `frozen` ticked with no
baseline, and nothing re-adopted one: the poll worker skips a frozen
entry whose frozen_value is None, so the Active box stayed on while the
freeze silently never wrote again until the user toggled it. The next
tick now adopts the first value read under the new type.
A value wider than its entry was written short without a word — the cell
kept showing all of it until the next tick corrected itself — because
prepare_write treats the entry width as a hard truncating cap. The
previous commit added exactly this guard for patterns and left String
and Byte Array silently dropping the tail; all three refuse it now.
An IDA pattern's promotion width was re-derived from the Value box,
which stays editable in pattern mode, so editing the pattern after a
scan promoted (or refreshed) at a width the hits were never found at —
the same staleness _last_scan_length exists to prevent. The pattern path
couldn't use it because a pattern request reports a length of 0, which
never promoted; the dispatched width is now the token count.
Also: the wildcard's out-of-range message read as though the entry were
permanently too narrow, when what it measures is the last read, which a
narrower write shortens until the next tick.
…ress
Three misses from the same habit — fixing a case instead of the rule.
add_entry's "address already exists" branch is the third place a
spec_label changes, and the previous commit fixed the other two. Promote
an address already in the table under a different type — from a scan or
from either pointer dialog — and the value the old spec decoded survived
into _rebuild, where the new spec's formatter met it:
_fmt_bytes("hello") raises ValueError from inside add_entry itself.
The width guard was in two places and wrong in both. On the parse_write
path it existed only for IDA patterns, so a regex entry got exactly the
silent truncation the guard was added to stop. And it counted len(value)
— characters for str — against a width that is a byte count, so "ábc"
(3 characters, 4 bytes) passed the check for a 3-byte entry and
prepare_write put a byte through the far wall. There is now one guard,
after the value exists, measuring what actually goes on the wire; it
covers String, Byte Array, Regex and IDA patterns alike.
Not changed: current_spec_and_length still reads the live Length field
for a regex rather than _last_scan_length. That field is the user's
byte_length and the only editable width in the app; an earlier review
flagged the opposite — that preferring the scan width left it inert —
and Next Scan is disabled in pattern mode, so no baseline comparison can
be thrown off by it. Editing it and refreshing is the control working.
Three from review of the previous commit.
Changing a row's type and writing a value in the same bulk edit failed
every selected row: the type change forgets the cached value, and the
write in the same iteration then had nothing for an IDA '?' to keep —
"can't be used before the value has been read at least once", about
bytes that were sitting right there. Byte Array → AOB doesn't change
what those bytes mean, and doing the two steps separately worked, which
is the tell. The write now captures the value before the forget runs.
Forgetting stays unconditional: a switch that keeps the pytype can still
change the width (Int32 → Int16), and parse_value_for_write already
ignores a `current` the new type could not have produced.
"Change buffer length" capped at max(1024, entry.length), so an entry
already wider than 1024 — which is normal now that entries are promoted
at the width of the value scanned for — could only ever be shrunk, while
a wider value couldn't be written into it either, since the width guard
refuses that. No way out from inside the app. It uses the same ceiling
the scanner does.
And the Type column showed the width only for specs that accept a length
override, which excludes the IDA pattern — the one spec whose width this
branch made real and enforced. Being told to widen an entry whose width
is nowhere on screen isn't much of an instruction.
Three dead ends the previous commit's guards created, all found by
review of it.
A bulk edit that retypes rows to a wider type refused every one of them.
Three Int32 rows retyped to String with "hello" hit "the entry holds 4 —
widen the entry first", and there is no width field in the bulk dialog,
nor a "change length" on a multi-row selection: no way out from inside
the app. A retype to a variable-width spec now takes the width from the
value it writes, which is what promotion does too.
The width ceiling went from a too-tight 1024 to no bound at all, and the
poll worker allocates the entry's width for every row on every 100 ms
tick — one typo away from a 2 GB allocation the tick's blanket except
swallows and retries forever. MAX_ENTRY_LENGTH bounds it at a megabyte,
far past any value a scan produces, and the manual-add dialog uses the
same number instead of disagreeing at 1024.
Re-arming a frozen row on the next tick after a type change traded a
no-op for a wrong write: with frozen_value gone and the box still
ticked, the tick adopted whatever the address happened to hold and the
app started pinning a value the user never chose. The type change
releases the freeze now. The re-arm stays for the case it was written
for — a box ticked before the first read, which is a deliberate action
on an unchanged type.
Not changed: a regex cell writes its text literally, so editing one that
held non-UTF-8 bytes writes U+FFFD back and the old tail survives past
the new text. That is how String (UTF-8) has always behaved — reads
decode with errors="replace" and prepare_write never pads — so it is a
property of writing text over binary, not of making regex writable.
Changing it means changing what a string write means.
@JeanExtreme002
JeanExtreme002 merged commit 7c7f80c into mainAug 28, 2026
13 checks passed
@github-actions
github-actionsBot deleted the fix/byte-array-infers-length branch August 28, 2026 20:50
@JeanExtreme002JeanExtreme002 mentioned this pull request Aug 28, 2026
2 tasks
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

appGUI application changes (PyMemoryEditor/app/)libLibrary changes (PyMemoryEditor/)testsTest changes (tests/)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Byte array search shouldn't need to specify length

1 participant

@JeanExtreme002
, '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

fix(app): infer the byte-array scan width from the value entered - #87

Merged
JeanExtreme002 merged 19 commits into
mainfrom
fix/byte-array-infers-length
Aug 28, 2026
Merged

fix(app): infer the byte-array scan width from the value entered#87
JeanExtreme002 merged 19 commits into
mainfrom
fix/byte-array-infers-length

Conversation

@JeanExtreme002

@JeanExtreme002JeanExtreme002 commented Aug 27, 2026

Copy link
Copy Markdown
Owner

Why is this PR necessary, what does it do?

Fixes#79, and the run of related width bugs that surfaced while doing it.

The reported bug

Scanning for a Byte Array (Hex) required the user to keep the Length field
in sync by hand with the bytes they typed, and got both failure modes wrong:

  • a value wider than Length was squeezed into a fixed-width ctypes buffer
    and the scan died with ValueError: byte string too long. The default Length
    is 4, so any 5-byte value hit this — exactly what Byte array search shouldn't need to specify length #79 reports;
  • a value narrower than Length was NUL-padded, silently turning the scan
    into "these bytes followed by zeros": no error, just a scan that finds
    nothing and never says why.

String (UTF-8) already derived its width from the typed text, and the library
itself infers it in resolve_bufflength_for_value — the scanner panel was the
only place still asking the user to count. Byte Array now follows the same rule,
and the Length field became a read-only readout of what the value sizes to.
Partial matching keeps its own value type (AOB Pattern (IDA)).

One width, one source

Fixing that exposed the same mistake in every other place that needed "the width
of the rows currently on screen" and read the Length field instead — which
follows the Value box, and the user can retype that between scans:

  • promote to cheat table created entries at the wrong width (a 4-byte olá
    scan promoted at the spec's 16, so the cell pulled 12 bytes of neighbouring
    memory in on every poll tick);
  • the no-value comparisons (Changed / Unchanged / Increased / Decreased)
    re-read at a width the baseline was never recorded at, so every address
    reported as changed;
  • Update Values re-read at whatever was in the Value box, overwriting every
    stored value with a truncated read — and aborted outright with
    "Invalid value" if that box happened to be empty;
  • the readout ignored a range's upper bound while the scan sized with
    max(lo, hi).

They all take it from _last_scan_length now — the width of the scan that
produced the rows — which is adopted when a scan lands, not when it is
dispatched, so one that errors, is cancelled, or is rejected by its handler
can't leave a width behind for a later refresh to pick up. is_sized_by_value()
replaced the "String or Byte Array, but not a pattern" test that was being
spelled out by hand at each site, which is what kept producing these.

Pattern types now round-trip as cheat entries

Giving AOB entries a real width made their cheat-table cell editable for the
first time, and it was wired backwards: the cell formats what it reads as hex
but parsed an edit as a pattern, so typing 00 wrote ASCII 0x30 0x30 into
the target and reported success.

The cause was the spec answering one question. spec.parse answers the
scanner's — "what am I searching for?" — and for an IDA pattern that is the
pattern text, which is not a value any address can hold. A cheat entry is
read and written every poll tick, so its type has to round-trip:
format(bytes read) must parse back to the same bytes.

The specs answer both questions now. ValueTypeSpec.parse_write turns cell text
into the bytes to write and receives the value last read at the address;
parse_value_for_write picks it when a spec has one. The scanner keeps asking
parse_value — it only ever searches — and the cheat table only ever writes, so
it no longer imports parse_value at all.

For an IDA pattern the tokens resolve to the bytes they name, and ? keeps the
byte already at that offset:

cell shows 48 8B 12 34 00
edit "48 8B 90 90 00" → writes 48 8B 90 90 00
edit "48 8B ? ? 90" → writes 48 8B 12 34 90 (the ?s are preserved)

That mirrors the wildcard's search meaning and is what makes a signature
patchable — you name the bytes you mean to change and leave the operands alone,
the same semantics Cheat Engine uses. A wildcard with nothing read yet is
refused with a message rather than writing a zero. A regex names a set of byte
strings rather than one, so there the cell text is taken literally, matching what
the cell displays. tokenize_pattern comes out of compile_pattern so the
search and write paths share one set of token rules and error messages.

Writing a value must not resize the entry that holds it, either. parse_value
honours an explicit length override, so before this no value edit ever changed
an entry's width; the new write path returned len(value) unconditionally, and
Regex does accept an override, so bulk-editing a 64-byte regex entry to "hi"
collapsed it to 2 bytes and the row displayed 2 bytes of the address from then
on. The write path follows the same override rule as the search path now.

Neighbouring, same cause: every path that fell back to the AOB spec's declared
length of 0 — promotion, adding an address by hand, changing an entry's type,
importing a JSON table — minted a zero-width entry that read back empty on every
tick. add_entry floors the width, since it is the one door all of those pass
through.

The pointer dialogs keep the regex type

Both dialogs read a value at an address the user already has, and both filtered
their "Read value as" list by is_pattern — which lumps together two specs that
behave nothing alike once the address is known.

An IDA pattern genuinely can't be offered there: it declares a length of 0 (the
scanner derives a match's width from the pattern) and refuses a length
override, so the dialog read zero bytes and displayed nothing. A regex has
neither problem — its width comes from the Length field, and it renders bytes as
text up to the first NUL, which String (UTF-8) does not:

memory: b"Player42\0\xff\xfe\x01rest"
Regex (String) → "Player42"
String (UTF-8) → "Player42\0\ufffd\ufffd\x01rest"

So it isn't a duplicate of anything else in the list. has_readable_width()
replaces the is_pattern test and asks what actually matters — can this spec
size a read at an address I already have? — derived from the spec rather than
from a label. Pointer Scan had the old filter before this branch and Pointer
Chain gained it during it; both were dropping the regex for the same wrong
reason, so both are corrected. The cheat table keeps offering both types, since
an entry carries its own width.

Comparisons that never worked

Increased/Decreased Value By on String / Byte Array: the comparator does
prev + exp, which concatenates rather than adds and is never equal to the
fixed-width value read back, while prev - exp raises TypeError that the
refine worker swallows into "doesn't match". Every address was dropped in
silence. The combination is rejected with a message pointing at Changed /
Unchanged Value. An empty String value likewise sized to a 1-byte NUL
buffer, so scanning for it matched every zeroed byte in the target; Byte Array
already rejected its own empty input.

Checklist (complete all items):

  • Added tests as necessary.
  • There is no breaking change for existing features.

References:

Closes#79

Notes:

Reproduction of the reported bug, and the same call after the fix:

build_scan_request(BYTES, EXACT_VALUE, value_text='00 11 22 AA BB CC', length_spin_value=4)
# before → length=4 → value_to_bytes(...) → ValueError: byte string too long# after → length=6 → value_to_bytes(...) → b'\x00\x11"\xaa\xbb\xcc'

Deliberately left alone: for VALUE_BETWEEN with endpoints of different widths
the shorter one is still NUL-padded up to the wider. That is documented library
behaviour (resolve_bufflength_for_value), it is what makes a fixed-width
comparison possible when the user gives two different widths, and str has
always behaved the same way.

Suite goes from 486 to 520 passing with no new failures; make lint is clean and
make type-check reports the same pre-existing errors as main (none in the
files touched). The 15 still-failing tests are unrelated to this branch: they are the macOS
KERN_MEMORY_ERROR scan abort fixed by #88, and they fail the same way on
main. With both branches merged locally the whole suite passes — 538 tests,
no failures — which is the first time the real memory read/write path has been
exercised here at all.

Scanning for a Byte Array (Hex) required the user to keep the Length
field in sync with the bytes they typed, and got both failure modes
wrong when they didn't:
* a value wider than Length was squeezed into a fixed-width ctypes
buffer and the scan died with "ValueError: byte string too long"
(the default Length is 4, so any 5-byte value hit this);
* a value narrower than Length was NUL-padded, silently turning the
scan into "these bytes followed by zeros" with no feedback.
String (UTF-8) already derived its width from the typed text, and the
library itself infers it in resolve_bufflength_for_value — the scanner
panel was the only place still asking. Byte Array now follows the same
rule: the Length field becomes a read-only readout of the parsed byte
count, and build_scan_request lets parse_value size the buffer. Partial
matching keeps its own value type (AOB Pattern).
This also fixes promoting byte-array results to the cheat table, which
read the same spin box and so created entries truncated to 4 bytes.
The no-value comparisons (Increased / Changed / …) still read the
Length field: they carry no value to measure, and the readout holds the
width the previous scan settled on.
Closes#79
@github-actionsgithub-actionsBot added app GUI application changes (PyMemoryEditor/app/) lib Library changes (PyMemoryEditor/) tests Test changes (tests/) labels Aug 27, 2026
Two problems in the same neighbourhood, surfaced by review of the
byte-array fix.
The no-value comparisons (Increased / Changed / …) carry no value to
measure, so they read the Length field — but str was excluded and fell
back to the spec default of 16. Picking "Changed Value" after a 4-byte
"olá" scan refined it by re-reading 16 bytes per address, so the value
read back ("olá\0\0…") never equalled the "olá" the first scan had
recorded and every address reported as Changed. The readout now holds
its last valid width when the value field is cleared (it already did
for byte arrays), and the no-value branch honours it for both types.
Promoting such a row to the cheat table was hitting the same 1-byte
readout and is fixed with it.
Switching value type left the readout showing a width computed for the
previous type when the value doesn't parse as the new one — 21 bytes of
string carried over into Byte Array, for instance. That field is
read-only now, so the user had no way to correct it. Selecting a
value-sized type seeds the spec default before syncing.
Follow-up to #79.
Opening the app lands on Int32, whose Length reads 4. Picking Byte
Array (Hex) kept showing that 4 — it was never a width the byte-array
scan would use — and the first hex digit typed dropped it to 1, so the
one number the field ever showed on its own was wrong.
String and Byte Array take their width from the value, and the field is
read-only for them, so there is nothing to show and no way for the user
to correct whatever we invent. The spin now opens a 0 slot rendered as
"— (set by the value)" for that state, and fills in a real number as
soon as the value sizes to one. The two consumers that could see the 0
(the no-value next-scan width and the promote-to-cheat-table length)
fall back to the spec default.
Selecting any other type clears the special-value text and restores the
minimum of 1, so a 1-byte Int8 shows "1 bytes" rather than landing on
the empty-slot rendering.
Follow-up to #79.
…ded with
Increased / Decreased / Changed / Unchanged compare against the values
the previous scan recorded, so they have to re-read at that scan's
width. They were reading the Length field instead, which tracks the
value currently in the Value box — and the user can edit that between
scans without rescanning. First Scan for the byte array `00 11`, type
`00 11 22 33`, pick "Changed Value", Next Scan: every address is
re-read 4 bytes wide, compared against a 2-byte baseline, and reported
as Changed. "Update Values" was worse, writing the mismatched reads
back over the baseline for every later comparison.
The panel now records the width of each scan it emits and passes it as
previous_scan_length; it's dropped along with the results it describes.
The Length readout no longer has to double as that memory, so it goes
back to being exactly what the current value sizes to — which also
fixes it claiming "4 bytes" for a String whose value box had been
cleared while the request was built at length 1.
Two neighbouring bugs on the same paths:
* promoting an AOB hit to the cheat table created a zero-width entry
(no Length field, and the IDA spec's length is 0), re-read as empty on
every poll tick. It now measures the pattern — one token, one byte;
* an empty String value parsed to a 1-byte NUL buffer, so scanning it
matched every zeroed byte in the target. Byte Array already rejected
its own empty input; both now do.
Follow-up to #79.
…ade them
Third round of the same mistake, in the paths the previous fix didn't
reach. All four take the width from the Length readout, which follows
the Value box — a no-value scan type clears that box outright, and the
user can retype it between scans without rescanning:
* promoting to the cheat table fell through to the spec default: a
4-byte "olá" scan promoted at 16, so the entry pulled 12 bytes of
neighbouring memory into the cell on every poll tick;
* "Increased/Decreased value BY" sit outside NO_VALUE_SCAN_TYPES, so
they missed the previous fix and sized the re-read from the *delta*
text — a 4-byte baseline re-read 1 byte at a time, dropping every
address, and then written back as the new baseline width;
* "Update Values" is a read-only refresh, but re-read at whatever was
in the Value box and moved the baseline to match, overwriting every
stored value with a truncated read;
* the readout ignored the range upper bound while the scan sized with
max(lo, hi), so a "AA".."AA BB CC" range reported 1 byte and ran at 3.
All four now use the width the results were scanned at. _sync_value_length
reads both bounds and no longer needs its callers to check the type.
Also: re-picking "AOB Pattern (IDA)" on a promoted hit reset the entry
to a zero-width buffer (the spec's own length is 0), undoing the pattern
width from the other side; and the empty-value message is neutral now
that it surfaces in the cheat table's write dialogs too.
Follow-up to #79.
…ng it
Fourth round on the same class, so this one names the concept instead of
patching another site: is_sized_by_value(spec) is now a single predicate
the panel uses everywhere it previously spelled out "String or Byte
Array, but not a pattern" by hand — which is what kept producing these.
That gate was wrong in one place already: accepts_length_override is
True for Regex too, so the width override caught it and made its Length
field — the only editable one, and the user's own byte_length — inert
after a first scan. Update Values and promote honour it again.
Update Values was building a full request just to throw the value away
(RefineScanWorker applies no comparison when filter_only is False). With
the new empty-value rejection that turned into a hard regression: a
Value box cleared by a no-value scan type aborted the refresh with
"Invalid value" instead of refreshing. It builds the request directly
now and can't fail.
And the baseline width is adopted when a scan *lands*, not when it is
dispatched: set_has_results is called by the owner on completion, so a
Next Scan whose worker errors out no longer leaves a width the values on
screen were never read at — which the following Changed/Unchanged refine
would have compared against.
Also: bulk edit collapsed AOB entries to a zero-width buffer exactly as
_change_type did before the last commit, and manually adding an AOB
address created one from the start; both ask for or keep a real width.
Follow-up to #79.
Chasing the zero-width AOB entry one call site at a time found two more:
importing a cheat table whose rows were saved by a build that promoted
AOB hits at the spec's 0 (`raw.get("length") or spec.length` is `0 or 0`),
and the pointer-chain dialog, which — unlike the pointer-scan dialog
right next to it — offered the pattern types in its "Read value as"
combo, so `_current_spec()` handed back the spec's 0.
Rather than a fourth guard, the floor now sits in add_entry: every entry
enters the table through it, whichever way it was made — promoted from a
scan, from either pointer dialog, added by hand, or loaded from JSON.
A zero-width entry can't be spotted from the table (the Value column
just reads back empty forever), so this is worth enforcing once at the
boundary rather than trusting each producer.
The pointer-chain dialog also gets the filter its sibling already had,
with the same reasoning: reading a value "as a pattern" is meaningless
when the address is already known.
Follow-up to #79.
…r worked
Last round's baseline fix promoted a pending width on any report of
results, and I reasoned that only a completed scan could produce one.
That was wrong: "Update Values" finishes through the same handler. So a
Next Scan whose worker errored left its width behind, and the next plain
refresh adopted it — 2-byte values on screen, baseline claiming 4, and
the following Changed Value refine drops everything. The pending width is
now discarded when the scan cycle ends (set_busy(False), which the owner
emits after the completion signal), so only the scan that actually landed
can have set it.
"Increased/Decreased Value By" never worked on String / Byte Array: the
comparator does `prev + exp`, which concatenates rather than adds and is
never equal to the fixed-width value read back, while `prev - exp` raises
TypeError that the refine worker swallows into "doesn't match". Every
address was dropped and nothing said why. The combination is rejected
with a message pointing at Changed / Unchanged Value, and last round's
width branch for it goes away — it was sizing a comparison that could
never be true.
Also: the cheat table's "Change buffer length" dialog capped at 1024
while a promoted String / Byte Array entry can be as wide as the value
scanned for, so opening it and pressing OK silently shrank the entry.
Follow-up to #79.
… edges
Giving AOB entries a real width made their Value cell live for the first
time, and it was wired backwards: the cell formats the bytes it reads as
hex, but parses an edit as a *pattern*, so typing "00" wrote ASCII
0x30 0x30 rather than the byte 0x00 — reported as a successful write. A
pattern finds an address; it can't hold a value. Hits promote as Byte
Array at the pattern's width now, so the cell reads and writes the same
thing. The zero-width buffer used to make every such write fail, which
is why this never showed.
Two more edges on the pending-width adoption:
* a cancelled refine still reports results (the worker breaks out of its
loop and emits finished_ok with what it kept), but only the rows it
reached were re-read at the new width. Neither width describes the
table, so cancelling keeps the one most rows were read at;
* a request the owner rejects before the busy cycle begins (its handler
returns early) never reaches set_busy(False), so its width lingered.
"Update Values" now records the width it re-reads at — which is what
the table will hold once it lands, and overwrites anything stale.
Also: the Length field's enable expression still tested a clause that
can no longer be true, reading as if more types than Regex could make it
editable. Regex is the only spec whose width is genuinely the user's.
Follow-up to #79.
Self-review of the whole diff, on the final state rather than commit by
commit. Three things it turned up.
The write-ASCII bug wasn't fixed, only patched at one of its three
doors. Substituting Byte Array inside current_spec_and_length covered
promotion, but adding an address by hand with the AOB type, changing an
existing entry's type to AOB, and importing a JSON table written before
that change all still produced a pattern-typed entry — whose cell
displays hex and writes the ASCII spelling of what you type. A pattern
is search syntax: a way to find an address, never a shape a value is
read and written back as. The type pickers now offer HOLDABLE_TYPE_LABELS
(the non-pattern specs, the same filter the pointer dialogs already use)
and add_entry re-types anything that still arrives — it is the one door
promote, manual add and JSON import all pass through. The substitution
comes out of current_spec_and_length, which was also handing the wrong
spec to "Update Values"; three now-unreachable `or`-guards for the AOB
zero come out with it, since they implied a zero could still arrive.
The delta rejection sat before the pattern short-circuit. Both pattern
specs are bytes-typed, so an AOB scan with a delta type raised instead
of forcing EXACT as the pattern path documents — and pointed the user at
Changed/Unchanged Value, which are disabled in pattern mode. Moved
below, where it means what it says.
Also: "Update Values" carried two comments contradicting each other
about whether it moves the baseline (it does, to the width it re-reads
at), and the Length field's enable rule had two overlapping rationales
left over from when the expression was more than `is_regex`.
Follow-up to #79.
Reverts removing AOB / Regex from the cheat table in favour of fixing
what was actually broken about them.
A cheat entry is read and written every poll tick, so its type has to
round-trip: format(bytes read) must parse back to the same bytes. The
pattern specs failed that because `parse` answers a different question —
"what am I searching for?" — and for an IDA pattern it hands back the
pattern *text*, so a cell displaying `48 8B 00` wrote the ASCII spelling
of that hex.
The specs now answer both questions. `ValueTypeSpec.parse_write` turns
cell text into the bytes to write, and takes the value last read at the
address; `parse_value_for_write` picks it when a spec has one. The
scanner keeps asking `parse_value` — it only ever searches — and the
cheat table only ever writes, so it no longer imports `parse_value` at
all.
For an IDA pattern that means the tokens resolve to the bytes they name,
and `?` keeps the byte already at that offset: `48 8B ? ? 90` over
`48 8B 12 34 00` writes `48 8B 12 34 90`. That mirrors the wildcard's
search meaning and is what makes a signature patchable — you name the
bytes you mean to change and leave the operands alone. A wildcard with
nothing read yet is refused with a message rather than writing a zero.
For a regex, which names a set of byte strings rather than one, the cell
text is taken literally, matching what the cell displays.
`tokenize_pattern` comes out of `compile_pattern` so the search and write
paths share one set of token rules and error messages.
Follow-up to #79.
Self-review of the parse_write commit. The cheat table's bulk edit
stores the width parse_value_for_write returns back onto the entry, and
that width is how many bytes the row *reads* on every poll tick — set by
the user, and none of a write's business.
parse_value honours an explicit length_override for the types that
accept one, so before parse_write existed no value edit ever resized an
entry. The new path returned len(value) unconditionally, and Regex does
accept an override: bulk-editing a 64-byte regex entry to "hi" collapsed
it to 2 bytes, so the row then displayed 2 bytes of the address forever.
It now follows the same override rule as parse_value.
An IDA pattern is unaffected either way — it declines a length override,
so the cheat table never writes the returned width onto the entry — but
it now reports the pattern's own byte count rather than the search
path's 0, which is not a size any buffer could have.
Both pointer dialogs filtered their "Read value as" list by is_pattern,
which lumps together two specs that behave nothing alike once the
address is already known.
An IDA pattern genuinely can't be offered: it declares a length of 0 —
the scanner derives a match's width from the pattern — and refuses a
length override, so the dialog read zero bytes and displayed nothing. A
regex has neither problem: its width comes from the Length field, and it
renders the bytes as text up to the first NUL, which "String (UTF-8)"
does not. Reading b"Player42\0\xff\xfe\x01rest" shows "Player42" as a
regex and "Player42\0��\x01rest" as a string, so it isn't a
duplicate of anything else in the list.
has_readable_width() replaces the is_pattern test and asks the question
that actually matters — can this spec size a read at an address I
already have? — derived from the spec rather than from a label. The
cheat table keeps offering both, since an entry carries its own width.
Pointer Scan had this filter before the branch and Pointer Chain gained
it earlier in it; both were dropping the regex for the same wrong
reason, so both are corrected.
An entry's last_value / frozen_value hold whatever the *previous* spec
decoded, and nothing waits for a fresh poll tick after the type changes,
so the new spec was being applied to the old type's value. Two ways out:
* _rebuild formats it — _fmt_bytes(1234) raises TypeError from inside a
Qt slot, so an Int32 row retyped to Byte Array or AOB left the table
un-rebuilt; a frozen entry would also republish the old value to the
poll worker under the new pytype;
* the bulk edit's "set type" and "set value" run in the same pass, so an
IDA pattern's wildcard reached len() on an int. That raised TypeError
too, which neither call site's `except ValueError` catches.
Both branches now forget the cached value when the label actually
changes — the next tick refills it — and the pattern write path treats a
non-bytes `current` as "nothing read yet", so it stays inside the
ValueError contract its callers handle.
Cancelling a *first* scan no longer discards its width. Both workers
report partial results after a cancel, but only a refine leaves the rows
at mixed widths; every address a first scan found, it found at its own
width, and there is no earlier one to fall back on — so the next
Changed/Unchanged refine went to the spec default, 16 for a String
scanned at 4, which is the failure this branch exists to fix.
_has_results tells the two apart.
Also: an IDA pattern wider than the entry it is typed into was truncated
by prepare_write without a word, while the same edit one byte short was
refused by the wildcard check; it is refused now too. And
current_spec_and_length still documented a re-typing that was reverted
several commits ago.
tokenize_pattern comes back out of util.pattern.__all__ — it exists so
the scan and write paths share token rules, nothing outside the package
consumes it, and neither the guide nor the API reference describes it.
Follow-ups from review of the previous commit, three of them consequences
of it.
Dropping frozen_value on a type change left `frozen` ticked with no
baseline, and nothing re-adopted one: the poll worker skips a frozen
entry whose frozen_value is None, so the Active box stayed on while the
freeze silently never wrote again until the user toggled it. The next
tick now adopts the first value read under the new type.
A value wider than its entry was written short without a word — the cell
kept showing all of it until the next tick corrected itself — because
prepare_write treats the entry width as a hard truncating cap. The
previous commit added exactly this guard for patterns and left String
and Byte Array silently dropping the tail; all three refuse it now.
An IDA pattern's promotion width was re-derived from the Value box,
which stays editable in pattern mode, so editing the pattern after a
scan promoted (or refreshed) at a width the hits were never found at —
the same staleness _last_scan_length exists to prevent. The pattern path
couldn't use it because a pattern request reports a length of 0, which
never promoted; the dispatched width is now the token count.
Also: the wildcard's out-of-range message read as though the entry were
permanently too narrow, when what it measures is the last read, which a
narrower write shortens until the next tick.
…ress
Three misses from the same habit — fixing a case instead of the rule.
add_entry's "address already exists" branch is the third place a
spec_label changes, and the previous commit fixed the other two. Promote
an address already in the table under a different type — from a scan or
from either pointer dialog — and the value the old spec decoded survived
into _rebuild, where the new spec's formatter met it:
_fmt_bytes("hello") raises ValueError from inside add_entry itself.
The width guard was in two places and wrong in both. On the parse_write
path it existed only for IDA patterns, so a regex entry got exactly the
silent truncation the guard was added to stop. And it counted len(value)
— characters for str — against a width that is a byte count, so "ábc"
(3 characters, 4 bytes) passed the check for a 3-byte entry and
prepare_write put a byte through the far wall. There is now one guard,
after the value exists, measuring what actually goes on the wire; it
covers String, Byte Array, Regex and IDA patterns alike.
Not changed: current_spec_and_length still reads the live Length field
for a regex rather than _last_scan_length. That field is the user's
byte_length and the only editable width in the app; an earlier review
flagged the opposite — that preferring the scan width left it inert —
and Next Scan is disabled in pattern mode, so no baseline comparison can
be thrown off by it. Editing it and refreshing is the control working.
Three from review of the previous commit.
Changing a row's type and writing a value in the same bulk edit failed
every selected row: the type change forgets the cached value, and the
write in the same iteration then had nothing for an IDA '?' to keep —
"can't be used before the value has been read at least once", about
bytes that were sitting right there. Byte Array → AOB doesn't change
what those bytes mean, and doing the two steps separately worked, which
is the tell. The write now captures the value before the forget runs.
Forgetting stays unconditional: a switch that keeps the pytype can still
change the width (Int32 → Int16), and parse_value_for_write already
ignores a `current` the new type could not have produced.
"Change buffer length" capped at max(1024, entry.length), so an entry
already wider than 1024 — which is normal now that entries are promoted
at the width of the value scanned for — could only ever be shrunk, while
a wider value couldn't be written into it either, since the width guard
refuses that. No way out from inside the app. It uses the same ceiling
the scanner does.
And the Type column showed the width only for specs that accept a length
override, which excludes the IDA pattern — the one spec whose width this
branch made real and enforced. Being told to widen an entry whose width
is nowhere on screen isn't much of an instruction.
Three dead ends the previous commit's guards created, all found by
review of it.
A bulk edit that retypes rows to a wider type refused every one of them.
Three Int32 rows retyped to String with "hello" hit "the entry holds 4 —
widen the entry first", and there is no width field in the bulk dialog,
nor a "change length" on a multi-row selection: no way out from inside
the app. A retype to a variable-width spec now takes the width from the
value it writes, which is what promotion does too.
The width ceiling went from a too-tight 1024 to no bound at all, and the
poll worker allocates the entry's width for every row on every 100 ms
tick — one typo away from a 2 GB allocation the tick's blanket except
swallows and retries forever. MAX_ENTRY_LENGTH bounds it at a megabyte,
far past any value a scan produces, and the manual-add dialog uses the
same number instead of disagreeing at 1024.
Re-arming a frozen row on the next tick after a type change traded a
no-op for a wrong write: with frozen_value gone and the box still
ticked, the tick adopted whatever the address happened to hold and the
app started pinning a value the user never chose. The type change
releases the freeze now. The re-arm stays for the case it was written
for — a box ticked before the first read, which is a deliberate action
on an unchanged type.
Not changed: a regex cell writes its text literally, so editing one that
held non-UTF-8 bytes writes U+FFFD back and the old tail survives past
the new text. That is how String (UTF-8) has always behaved — reads
decode with errors="replace" and prepare_write never pads — so it is a
property of writing text over binary, not of making regex writable.
Changing it means changing what a string write means.
@JeanExtreme002
JeanExtreme002 merged commit 7c7f80c into mainAug 28, 2026
13 checks passed
@github-actions
github-actionsBot deleted the fix/byte-array-infers-length branch August 28, 2026 20:50
@JeanExtreme002JeanExtreme002 mentioned this pull request Aug 28, 2026
2 tasks
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

appGUI application changes (PyMemoryEditor/app/)libLibrary changes (PyMemoryEditor/)testsTest changes (tests/)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Byte array search shouldn't need to specify length

1 participant

@JeanExtreme002
, '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

fix(app): infer the byte-array scan width from the value entered - #87

Merged
JeanExtreme002 merged 19 commits into
mainfrom
fix/byte-array-infers-length
Aug 28, 2026
Merged

fix(app): infer the byte-array scan width from the value entered#87
JeanExtreme002 merged 19 commits into
mainfrom
fix/byte-array-infers-length

Conversation

@JeanExtreme002

@JeanExtreme002JeanExtreme002 commented Aug 27, 2026

Copy link
Copy Markdown
Owner

Why is this PR necessary, what does it do?

Fixes#79, and the run of related width bugs that surfaced while doing it.

The reported bug

Scanning for a Byte Array (Hex) required the user to keep the Length field
in sync by hand with the bytes they typed, and got both failure modes wrong:

  • a value wider than Length was squeezed into a fixed-width ctypes buffer
    and the scan died with ValueError: byte string too long. The default Length
    is 4, so any 5-byte value hit this — exactly what Byte array search shouldn't need to specify length #79 reports;
  • a value narrower than Length was NUL-padded, silently turning the scan
    into "these bytes followed by zeros": no error, just a scan that finds
    nothing and never says why.

String (UTF-8) already derived its width from the typed text, and the library
itself infers it in resolve_bufflength_for_value — the scanner panel was the
only place still asking the user to count. Byte Array now follows the same rule,
and the Length field became a read-only readout of what the value sizes to.
Partial matching keeps its own value type (AOB Pattern (IDA)).

One width, one source

Fixing that exposed the same mistake in every other place that needed "the width
of the rows currently on screen" and read the Length field instead — which
follows the Value box, and the user can retype that between scans:

  • promote to cheat table created entries at the wrong width (a 4-byte olá
    scan promoted at the spec's 16, so the cell pulled 12 bytes of neighbouring
    memory in on every poll tick);
  • the no-value comparisons (Changed / Unchanged / Increased / Decreased)
    re-read at a width the baseline was never recorded at, so every address
    reported as changed;
  • Update Values re-read at whatever was in the Value box, overwriting every
    stored value with a truncated read — and aborted outright with
    "Invalid value" if that box happened to be empty;
  • the readout ignored a range's upper bound while the scan sized with
    max(lo, hi).

They all take it from _last_scan_length now — the width of the scan that
produced the rows — which is adopted when a scan lands, not when it is
dispatched, so one that errors, is cancelled, or is rejected by its handler
can't leave a width behind for a later refresh to pick up. is_sized_by_value()
replaced the "String or Byte Array, but not a pattern" test that was being
spelled out by hand at each site, which is what kept producing these.

Pattern types now round-trip as cheat entries

Giving AOB entries a real width made their cheat-table cell editable for the
first time, and it was wired backwards: the cell formats what it reads as hex
but parsed an edit as a pattern, so typing 00 wrote ASCII 0x30 0x30 into
the target and reported success.

The cause was the spec answering one question. spec.parse answers the
scanner's — "what am I searching for?" — and for an IDA pattern that is the
pattern text, which is not a value any address can hold. A cheat entry is
read and written every poll tick, so its type has to round-trip:
format(bytes read) must parse back to the same bytes.

The specs answer both questions now. ValueTypeSpec.parse_write turns cell text
into the bytes to write and receives the value last read at the address;
parse_value_for_write picks it when a spec has one. The scanner keeps asking
parse_value — it only ever searches — and the cheat table only ever writes, so
it no longer imports parse_value at all.

For an IDA pattern the tokens resolve to the bytes they name, and ? keeps the
byte already at that offset:

cell shows 48 8B 12 34 00
edit "48 8B 90 90 00" → writes 48 8B 90 90 00
edit "48 8B ? ? 90" → writes 48 8B 12 34 90 (the ?s are preserved)

That mirrors the wildcard's search meaning and is what makes a signature
patchable — you name the bytes you mean to change and leave the operands alone,
the same semantics Cheat Engine uses. A wildcard with nothing read yet is
refused with a message rather than writing a zero. A regex names a set of byte
strings rather than one, so there the cell text is taken literally, matching what
the cell displays. tokenize_pattern comes out of compile_pattern so the
search and write paths share one set of token rules and error messages.

Writing a value must not resize the entry that holds it, either. parse_value
honours an explicit length override, so before this no value edit ever changed
an entry's width; the new write path returned len(value) unconditionally, and
Regex does accept an override, so bulk-editing a 64-byte regex entry to "hi"
collapsed it to 2 bytes and the row displayed 2 bytes of the address from then
on. The write path follows the same override rule as the search path now.

Neighbouring, same cause: every path that fell back to the AOB spec's declared
length of 0 — promotion, adding an address by hand, changing an entry's type,
importing a JSON table — minted a zero-width entry that read back empty on every
tick. add_entry floors the width, since it is the one door all of those pass
through.

The pointer dialogs keep the regex type

Both dialogs read a value at an address the user already has, and both filtered
their "Read value as" list by is_pattern — which lumps together two specs that
behave nothing alike once the address is known.

An IDA pattern genuinely can't be offered there: it declares a length of 0 (the
scanner derives a match's width from the pattern) and refuses a length
override, so the dialog read zero bytes and displayed nothing. A regex has
neither problem — its width comes from the Length field, and it renders bytes as
text up to the first NUL, which String (UTF-8) does not:

memory: b"Player42\0\xff\xfe\x01rest"
Regex (String) → "Player42"
String (UTF-8) → "Player42\0\ufffd\ufffd\x01rest"

So it isn't a duplicate of anything else in the list. has_readable_width()
replaces the is_pattern test and asks what actually matters — can this spec
size a read at an address I already have? — derived from the spec rather than
from a label. Pointer Scan had the old filter before this branch and Pointer
Chain gained it during it; both were dropping the regex for the same wrong
reason, so both are corrected. The cheat table keeps offering both types, since
an entry carries its own width.

Comparisons that never worked

Increased/Decreased Value By on String / Byte Array: the comparator does
prev + exp, which concatenates rather than adds and is never equal to the
fixed-width value read back, while prev - exp raises TypeError that the
refine worker swallows into "doesn't match". Every address was dropped in
silence. The combination is rejected with a message pointing at Changed /
Unchanged Value. An empty String value likewise sized to a 1-byte NUL
buffer, so scanning for it matched every zeroed byte in the target; Byte Array
already rejected its own empty input.

Checklist (complete all items):

  • Added tests as necessary.
  • There is no breaking change for existing features.

References:

Closes#79

Notes:

Reproduction of the reported bug, and the same call after the fix:

build_scan_request(BYTES, EXACT_VALUE, value_text='00 11 22 AA BB CC', length_spin_value=4)
# before → length=4 → value_to_bytes(...) → ValueError: byte string too long# after → length=6 → value_to_bytes(...) → b'\x00\x11"\xaa\xbb\xcc'

Deliberately left alone: for VALUE_BETWEEN with endpoints of different widths
the shorter one is still NUL-padded up to the wider. That is documented library
behaviour (resolve_bufflength_for_value), it is what makes a fixed-width
comparison possible when the user gives two different widths, and str has
always behaved the same way.

Suite goes from 486 to 520 passing with no new failures; make lint is clean and
make type-check reports the same pre-existing errors as main (none in the
files touched). The 15 still-failing tests are unrelated to this branch: they are the macOS
KERN_MEMORY_ERROR scan abort fixed by #88, and they fail the same way on
main. With both branches merged locally the whole suite passes — 538 tests,
no failures — which is the first time the real memory read/write path has been
exercised here at all.

Scanning for a Byte Array (Hex) required the user to keep the Length
field in sync with the bytes they typed, and got both failure modes
wrong when they didn't:
* a value wider than Length was squeezed into a fixed-width ctypes
buffer and the scan died with "ValueError: byte string too long"
(the default Length is 4, so any 5-byte value hit this);
* a value narrower than Length was NUL-padded, silently turning the
scan into "these bytes followed by zeros" with no feedback.
String (UTF-8) already derived its width from the typed text, and the
library itself infers it in resolve_bufflength_for_value — the scanner
panel was the only place still asking. Byte Array now follows the same
rule: the Length field becomes a read-only readout of the parsed byte
count, and build_scan_request lets parse_value size the buffer. Partial
matching keeps its own value type (AOB Pattern).
This also fixes promoting byte-array results to the cheat table, which
read the same spin box and so created entries truncated to 4 bytes.
The no-value comparisons (Increased / Changed / …) still read the
Length field: they carry no value to measure, and the readout holds the
width the previous scan settled on.
Closes#79
@github-actionsgithub-actionsBot added app GUI application changes (PyMemoryEditor/app/) lib Library changes (PyMemoryEditor/) tests Test changes (tests/) labels Aug 27, 2026
Two problems in the same neighbourhood, surfaced by review of the
byte-array fix.
The no-value comparisons (Increased / Changed / …) carry no value to
measure, so they read the Length field — but str was excluded and fell
back to the spec default of 16. Picking "Changed Value" after a 4-byte
"olá" scan refined it by re-reading 16 bytes per address, so the value
read back ("olá\0\0…") never equalled the "olá" the first scan had
recorded and every address reported as Changed. The readout now holds
its last valid width when the value field is cleared (it already did
for byte arrays), and the no-value branch honours it for both types.
Promoting such a row to the cheat table was hitting the same 1-byte
readout and is fixed with it.
Switching value type left the readout showing a width computed for the
previous type when the value doesn't parse as the new one — 21 bytes of
string carried over into Byte Array, for instance. That field is
read-only now, so the user had no way to correct it. Selecting a
value-sized type seeds the spec default before syncing.
Follow-up to #79.
Opening the app lands on Int32, whose Length reads 4. Picking Byte
Array (Hex) kept showing that 4 — it was never a width the byte-array
scan would use — and the first hex digit typed dropped it to 1, so the
one number the field ever showed on its own was wrong.
String and Byte Array take their width from the value, and the field is
read-only for them, so there is nothing to show and no way for the user
to correct whatever we invent. The spin now opens a 0 slot rendered as
"— (set by the value)" for that state, and fills in a real number as
soon as the value sizes to one. The two consumers that could see the 0
(the no-value next-scan width and the promote-to-cheat-table length)
fall back to the spec default.
Selecting any other type clears the special-value text and restores the
minimum of 1, so a 1-byte Int8 shows "1 bytes" rather than landing on
the empty-slot rendering.
Follow-up to #79.
…ded with
Increased / Decreased / Changed / Unchanged compare against the values
the previous scan recorded, so they have to re-read at that scan's
width. They were reading the Length field instead, which tracks the
value currently in the Value box — and the user can edit that between
scans without rescanning. First Scan for the byte array `00 11`, type
`00 11 22 33`, pick "Changed Value", Next Scan: every address is
re-read 4 bytes wide, compared against a 2-byte baseline, and reported
as Changed. "Update Values" was worse, writing the mismatched reads
back over the baseline for every later comparison.
The panel now records the width of each scan it emits and passes it as
previous_scan_length; it's dropped along with the results it describes.
The Length readout no longer has to double as that memory, so it goes
back to being exactly what the current value sizes to — which also
fixes it claiming "4 bytes" for a String whose value box had been
cleared while the request was built at length 1.
Two neighbouring bugs on the same paths:
* promoting an AOB hit to the cheat table created a zero-width entry
(no Length field, and the IDA spec's length is 0), re-read as empty on
every poll tick. It now measures the pattern — one token, one byte;
* an empty String value parsed to a 1-byte NUL buffer, so scanning it
matched every zeroed byte in the target. Byte Array already rejected
its own empty input; both now do.
Follow-up to #79.
…ade them
Third round of the same mistake, in the paths the previous fix didn't
reach. All four take the width from the Length readout, which follows
the Value box — a no-value scan type clears that box outright, and the
user can retype it between scans without rescanning:
* promoting to the cheat table fell through to the spec default: a
4-byte "olá" scan promoted at 16, so the entry pulled 12 bytes of
neighbouring memory into the cell on every poll tick;
* "Increased/Decreased value BY" sit outside NO_VALUE_SCAN_TYPES, so
they missed the previous fix and sized the re-read from the *delta*
text — a 4-byte baseline re-read 1 byte at a time, dropping every
address, and then written back as the new baseline width;
* "Update Values" is a read-only refresh, but re-read at whatever was
in the Value box and moved the baseline to match, overwriting every
stored value with a truncated read;
* the readout ignored the range upper bound while the scan sized with
max(lo, hi), so a "AA".."AA BB CC" range reported 1 byte and ran at 3.
All four now use the width the results were scanned at. _sync_value_length
reads both bounds and no longer needs its callers to check the type.
Also: re-picking "AOB Pattern (IDA)" on a promoted hit reset the entry
to a zero-width buffer (the spec's own length is 0), undoing the pattern
width from the other side; and the empty-value message is neutral now
that it surfaces in the cheat table's write dialogs too.
Follow-up to #79.
…ng it
Fourth round on the same class, so this one names the concept instead of
patching another site: is_sized_by_value(spec) is now a single predicate
the panel uses everywhere it previously spelled out "String or Byte
Array, but not a pattern" by hand — which is what kept producing these.
That gate was wrong in one place already: accepts_length_override is
True for Regex too, so the width override caught it and made its Length
field — the only editable one, and the user's own byte_length — inert
after a first scan. Update Values and promote honour it again.
Update Values was building a full request just to throw the value away
(RefineScanWorker applies no comparison when filter_only is False). With
the new empty-value rejection that turned into a hard regression: a
Value box cleared by a no-value scan type aborted the refresh with
"Invalid value" instead of refreshing. It builds the request directly
now and can't fail.
And the baseline width is adopted when a scan *lands*, not when it is
dispatched: set_has_results is called by the owner on completion, so a
Next Scan whose worker errors out no longer leaves a width the values on
screen were never read at — which the following Changed/Unchanged refine
would have compared against.
Also: bulk edit collapsed AOB entries to a zero-width buffer exactly as
_change_type did before the last commit, and manually adding an AOB
address created one from the start; both ask for or keep a real width.
Follow-up to #79.
Chasing the zero-width AOB entry one call site at a time found two more:
importing a cheat table whose rows were saved by a build that promoted
AOB hits at the spec's 0 (`raw.get("length") or spec.length` is `0 or 0`),
and the pointer-chain dialog, which — unlike the pointer-scan dialog
right next to it — offered the pattern types in its "Read value as"
combo, so `_current_spec()` handed back the spec's 0.
Rather than a fourth guard, the floor now sits in add_entry: every entry
enters the table through it, whichever way it was made — promoted from a
scan, from either pointer dialog, added by hand, or loaded from JSON.
A zero-width entry can't be spotted from the table (the Value column
just reads back empty forever), so this is worth enforcing once at the
boundary rather than trusting each producer.
The pointer-chain dialog also gets the filter its sibling already had,
with the same reasoning: reading a value "as a pattern" is meaningless
when the address is already known.
Follow-up to #79.
…r worked
Last round's baseline fix promoted a pending width on any report of
results, and I reasoned that only a completed scan could produce one.
That was wrong: "Update Values" finishes through the same handler. So a
Next Scan whose worker errored left its width behind, and the next plain
refresh adopted it — 2-byte values on screen, baseline claiming 4, and
the following Changed Value refine drops everything. The pending width is
now discarded when the scan cycle ends (set_busy(False), which the owner
emits after the completion signal), so only the scan that actually landed
can have set it.
"Increased/Decreased Value By" never worked on String / Byte Array: the
comparator does `prev + exp`, which concatenates rather than adds and is
never equal to the fixed-width value read back, while `prev - exp` raises
TypeError that the refine worker swallows into "doesn't match". Every
address was dropped and nothing said why. The combination is rejected
with a message pointing at Changed / Unchanged Value, and last round's
width branch for it goes away — it was sizing a comparison that could
never be true.
Also: the cheat table's "Change buffer length" dialog capped at 1024
while a promoted String / Byte Array entry can be as wide as the value
scanned for, so opening it and pressing OK silently shrank the entry.
Follow-up to #79.
… edges
Giving AOB entries a real width made their Value cell live for the first
time, and it was wired backwards: the cell formats the bytes it reads as
hex, but parses an edit as a *pattern*, so typing "00" wrote ASCII
0x30 0x30 rather than the byte 0x00 — reported as a successful write. A
pattern finds an address; it can't hold a value. Hits promote as Byte
Array at the pattern's width now, so the cell reads and writes the same
thing. The zero-width buffer used to make every such write fail, which
is why this never showed.
Two more edges on the pending-width adoption:
* a cancelled refine still reports results (the worker breaks out of its
loop and emits finished_ok with what it kept), but only the rows it
reached were re-read at the new width. Neither width describes the
table, so cancelling keeps the one most rows were read at;
* a request the owner rejects before the busy cycle begins (its handler
returns early) never reaches set_busy(False), so its width lingered.
"Update Values" now records the width it re-reads at — which is what
the table will hold once it lands, and overwrites anything stale.
Also: the Length field's enable expression still tested a clause that
can no longer be true, reading as if more types than Regex could make it
editable. Regex is the only spec whose width is genuinely the user's.
Follow-up to #79.
Self-review of the whole diff, on the final state rather than commit by
commit. Three things it turned up.
The write-ASCII bug wasn't fixed, only patched at one of its three
doors. Substituting Byte Array inside current_spec_and_length covered
promotion, but adding an address by hand with the AOB type, changing an
existing entry's type to AOB, and importing a JSON table written before
that change all still produced a pattern-typed entry — whose cell
displays hex and writes the ASCII spelling of what you type. A pattern
is search syntax: a way to find an address, never a shape a value is
read and written back as. The type pickers now offer HOLDABLE_TYPE_LABELS
(the non-pattern specs, the same filter the pointer dialogs already use)
and add_entry re-types anything that still arrives — it is the one door
promote, manual add and JSON import all pass through. The substitution
comes out of current_spec_and_length, which was also handing the wrong
spec to "Update Values"; three now-unreachable `or`-guards for the AOB
zero come out with it, since they implied a zero could still arrive.
The delta rejection sat before the pattern short-circuit. Both pattern
specs are bytes-typed, so an AOB scan with a delta type raised instead
of forcing EXACT as the pattern path documents — and pointed the user at
Changed/Unchanged Value, which are disabled in pattern mode. Moved
below, where it means what it says.
Also: "Update Values" carried two comments contradicting each other
about whether it moves the baseline (it does, to the width it re-reads
at), and the Length field's enable rule had two overlapping rationales
left over from when the expression was more than `is_regex`.
Follow-up to #79.
Reverts removing AOB / Regex from the cheat table in favour of fixing
what was actually broken about them.
A cheat entry is read and written every poll tick, so its type has to
round-trip: format(bytes read) must parse back to the same bytes. The
pattern specs failed that because `parse` answers a different question —
"what am I searching for?" — and for an IDA pattern it hands back the
pattern *text*, so a cell displaying `48 8B 00` wrote the ASCII spelling
of that hex.
The specs now answer both questions. `ValueTypeSpec.parse_write` turns
cell text into the bytes to write, and takes the value last read at the
address; `parse_value_for_write` picks it when a spec has one. The
scanner keeps asking `parse_value` — it only ever searches — and the
cheat table only ever writes, so it no longer imports `parse_value` at
all.
For an IDA pattern that means the tokens resolve to the bytes they name,
and `?` keeps the byte already at that offset: `48 8B ? ? 90` over
`48 8B 12 34 00` writes `48 8B 12 34 90`. That mirrors the wildcard's
search meaning and is what makes a signature patchable — you name the
bytes you mean to change and leave the operands alone. A wildcard with
nothing read yet is refused with a message rather than writing a zero.
For a regex, which names a set of byte strings rather than one, the cell
text is taken literally, matching what the cell displays.
`tokenize_pattern` comes out of `compile_pattern` so the search and write
paths share one set of token rules and error messages.
Follow-up to #79.
Self-review of the parse_write commit. The cheat table's bulk edit
stores the width parse_value_for_write returns back onto the entry, and
that width is how many bytes the row *reads* on every poll tick — set by
the user, and none of a write's business.
parse_value honours an explicit length_override for the types that
accept one, so before parse_write existed no value edit ever resized an
entry. The new path returned len(value) unconditionally, and Regex does
accept an override: bulk-editing a 64-byte regex entry to "hi" collapsed
it to 2 bytes, so the row then displayed 2 bytes of the address forever.
It now follows the same override rule as parse_value.
An IDA pattern is unaffected either way — it declines a length override,
so the cheat table never writes the returned width onto the entry — but
it now reports the pattern's own byte count rather than the search
path's 0, which is not a size any buffer could have.
Both pointer dialogs filtered their "Read value as" list by is_pattern,
which lumps together two specs that behave nothing alike once the
address is already known.
An IDA pattern genuinely can't be offered: it declares a length of 0 —
the scanner derives a match's width from the pattern — and refuses a
length override, so the dialog read zero bytes and displayed nothing. A
regex has neither problem: its width comes from the Length field, and it
renders the bytes as text up to the first NUL, which "String (UTF-8)"
does not. Reading b"Player42\0\xff\xfe\x01rest" shows "Player42" as a
regex and "Player42\0��\x01rest" as a string, so it isn't a
duplicate of anything else in the list.
has_readable_width() replaces the is_pattern test and asks the question
that actually matters — can this spec size a read at an address I
already have? — derived from the spec rather than from a label. The
cheat table keeps offering both, since an entry carries its own width.
Pointer Scan had this filter before the branch and Pointer Chain gained
it earlier in it; both were dropping the regex for the same wrong
reason, so both are corrected.
An entry's last_value / frozen_value hold whatever the *previous* spec
decoded, and nothing waits for a fresh poll tick after the type changes,
so the new spec was being applied to the old type's value. Two ways out:
* _rebuild formats it — _fmt_bytes(1234) raises TypeError from inside a
Qt slot, so an Int32 row retyped to Byte Array or AOB left the table
un-rebuilt; a frozen entry would also republish the old value to the
poll worker under the new pytype;
* the bulk edit's "set type" and "set value" run in the same pass, so an
IDA pattern's wildcard reached len() on an int. That raised TypeError
too, which neither call site's `except ValueError` catches.
Both branches now forget the cached value when the label actually
changes — the next tick refills it — and the pattern write path treats a
non-bytes `current` as "nothing read yet", so it stays inside the
ValueError contract its callers handle.
Cancelling a *first* scan no longer discards its width. Both workers
report partial results after a cancel, but only a refine leaves the rows
at mixed widths; every address a first scan found, it found at its own
width, and there is no earlier one to fall back on — so the next
Changed/Unchanged refine went to the spec default, 16 for a String
scanned at 4, which is the failure this branch exists to fix.
_has_results tells the two apart.
Also: an IDA pattern wider than the entry it is typed into was truncated
by prepare_write without a word, while the same edit one byte short was
refused by the wildcard check; it is refused now too. And
current_spec_and_length still documented a re-typing that was reverted
several commits ago.
tokenize_pattern comes back out of util.pattern.__all__ — it exists so
the scan and write paths share token rules, nothing outside the package
consumes it, and neither the guide nor the API reference describes it.
Follow-ups from review of the previous commit, three of them consequences
of it.
Dropping frozen_value on a type change left `frozen` ticked with no
baseline, and nothing re-adopted one: the poll worker skips a frozen
entry whose frozen_value is None, so the Active box stayed on while the
freeze silently never wrote again until the user toggled it. The next
tick now adopts the first value read under the new type.
A value wider than its entry was written short without a word — the cell
kept showing all of it until the next tick corrected itself — because
prepare_write treats the entry width as a hard truncating cap. The
previous commit added exactly this guard for patterns and left String
and Byte Array silently dropping the tail; all three refuse it now.
An IDA pattern's promotion width was re-derived from the Value box,
which stays editable in pattern mode, so editing the pattern after a
scan promoted (or refreshed) at a width the hits were never found at —
the same staleness _last_scan_length exists to prevent. The pattern path
couldn't use it because a pattern request reports a length of 0, which
never promoted; the dispatched width is now the token count.
Also: the wildcard's out-of-range message read as though the entry were
permanently too narrow, when what it measures is the last read, which a
narrower write shortens until the next tick.
…ress
Three misses from the same habit — fixing a case instead of the rule.
add_entry's "address already exists" branch is the third place a
spec_label changes, and the previous commit fixed the other two. Promote
an address already in the table under a different type — from a scan or
from either pointer dialog — and the value the old spec decoded survived
into _rebuild, where the new spec's formatter met it:
_fmt_bytes("hello") raises ValueError from inside add_entry itself.
The width guard was in two places and wrong in both. On the parse_write
path it existed only for IDA patterns, so a regex entry got exactly the
silent truncation the guard was added to stop. And it counted len(value)
— characters for str — against a width that is a byte count, so "ábc"
(3 characters, 4 bytes) passed the check for a 3-byte entry and
prepare_write put a byte through the far wall. There is now one guard,
after the value exists, measuring what actually goes on the wire; it
covers String, Byte Array, Regex and IDA patterns alike.
Not changed: current_spec_and_length still reads the live Length field
for a regex rather than _last_scan_length. That field is the user's
byte_length and the only editable width in the app; an earlier review
flagged the opposite — that preferring the scan width left it inert —
and Next Scan is disabled in pattern mode, so no baseline comparison can
be thrown off by it. Editing it and refreshing is the control working.
Three from review of the previous commit.
Changing a row's type and writing a value in the same bulk edit failed
every selected row: the type change forgets the cached value, and the
write in the same iteration then had nothing for an IDA '?' to keep —
"can't be used before the value has been read at least once", about
bytes that were sitting right there. Byte Array → AOB doesn't change
what those bytes mean, and doing the two steps separately worked, which
is the tell. The write now captures the value before the forget runs.
Forgetting stays unconditional: a switch that keeps the pytype can still
change the width (Int32 → Int16), and parse_value_for_write already
ignores a `current` the new type could not have produced.
"Change buffer length" capped at max(1024, entry.length), so an entry
already wider than 1024 — which is normal now that entries are promoted
at the width of the value scanned for — could only ever be shrunk, while
a wider value couldn't be written into it either, since the width guard
refuses that. No way out from inside the app. It uses the same ceiling
the scanner does.
And the Type column showed the width only for specs that accept a length
override, which excludes the IDA pattern — the one spec whose width this
branch made real and enforced. Being told to widen an entry whose width
is nowhere on screen isn't much of an instruction.
Three dead ends the previous commit's guards created, all found by
review of it.
A bulk edit that retypes rows to a wider type refused every one of them.
Three Int32 rows retyped to String with "hello" hit "the entry holds 4 —
widen the entry first", and there is no width field in the bulk dialog,
nor a "change length" on a multi-row selection: no way out from inside
the app. A retype to a variable-width spec now takes the width from the
value it writes, which is what promotion does too.
The width ceiling went from a too-tight 1024 to no bound at all, and the
poll worker allocates the entry's width for every row on every 100 ms
tick — one typo away from a 2 GB allocation the tick's blanket except
swallows and retries forever. MAX_ENTRY_LENGTH bounds it at a megabyte,
far past any value a scan produces, and the manual-add dialog uses the
same number instead of disagreeing at 1024.
Re-arming a frozen row on the next tick after a type change traded a
no-op for a wrong write: with frozen_value gone and the box still
ticked, the tick adopted whatever the address happened to hold and the
app started pinning a value the user never chose. The type change
releases the freeze now. The re-arm stays for the case it was written
for — a box ticked before the first read, which is a deliberate action
on an unchanged type.
Not changed: a regex cell writes its text literally, so editing one that
held non-UTF-8 bytes writes U+FFFD back and the old tail survives past
the new text. That is how String (UTF-8) has always behaved — reads
decode with errors="replace" and prepare_write never pads — so it is a
property of writing text over binary, not of making regex writable.
Changing it means changing what a string write means.
@JeanExtreme002
JeanExtreme002 merged commit 7c7f80c into mainAug 28, 2026
13 checks passed
@github-actions
github-actionsBot deleted the fix/byte-array-infers-length branch August 28, 2026 20:50
@JeanExtreme002JeanExtreme002 mentioned this pull request Aug 28, 2026
2 tasks
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

appGUI application changes (PyMemoryEditor/app/)libLibrary changes (PyMemoryEditor/)testsTest changes (tests/)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Byte array search shouldn't need to specify length

1 participant

@JeanExtreme002
, '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

fix(app): infer the byte-array scan width from the value entered - #87

Merged
JeanExtreme002 merged 19 commits into
mainfrom
fix/byte-array-infers-length
Aug 28, 2026
Merged

fix(app): infer the byte-array scan width from the value entered#87
JeanExtreme002 merged 19 commits into
mainfrom
fix/byte-array-infers-length

Conversation

@JeanExtreme002

@JeanExtreme002JeanExtreme002 commented Aug 27, 2026

Copy link
Copy Markdown
Owner

Why is this PR necessary, what does it do?

Fixes#79, and the run of related width bugs that surfaced while doing it.

The reported bug

Scanning for a Byte Array (Hex) required the user to keep the Length field
in sync by hand with the bytes they typed, and got both failure modes wrong:

  • a value wider than Length was squeezed into a fixed-width ctypes buffer
    and the scan died with ValueError: byte string too long. The default Length
    is 4, so any 5-byte value hit this — exactly what Byte array search shouldn't need to specify length #79 reports;
  • a value narrower than Length was NUL-padded, silently turning the scan
    into "these bytes followed by zeros": no error, just a scan that finds
    nothing and never says why.

String (UTF-8) already derived its width from the typed text, and the library
itself infers it in resolve_bufflength_for_value — the scanner panel was the
only place still asking the user to count. Byte Array now follows the same rule,
and the Length field became a read-only readout of what the value sizes to.
Partial matching keeps its own value type (AOB Pattern (IDA)).

One width, one source

Fixing that exposed the same mistake in every other place that needed "the width
of the rows currently on screen" and read the Length field instead — which
follows the Value box, and the user can retype that between scans:

  • promote to cheat table created entries at the wrong width (a 4-byte olá
    scan promoted at the spec's 16, so the cell pulled 12 bytes of neighbouring
    memory in on every poll tick);
  • the no-value comparisons (Changed / Unchanged / Increased / Decreased)
    re-read at a width the baseline was never recorded at, so every address
    reported as changed;
  • Update Values re-read at whatever was in the Value box, overwriting every
    stored value with a truncated read — and aborted outright with
    "Invalid value" if that box happened to be empty;
  • the readout ignored a range's upper bound while the scan sized with
    max(lo, hi).

They all take it from _last_scan_length now — the width of the scan that
produced the rows — which is adopted when a scan lands, not when it is
dispatched, so one that errors, is cancelled, or is rejected by its handler
can't leave a width behind for a later refresh to pick up. is_sized_by_value()
replaced the "String or Byte Array, but not a pattern" test that was being
spelled out by hand at each site, which is what kept producing these.

Pattern types now round-trip as cheat entries

Giving AOB entries a real width made their cheat-table cell editable for the
first time, and it was wired backwards: the cell formats what it reads as hex
but parsed an edit as a pattern, so typing 00 wrote ASCII 0x30 0x30 into
the target and reported success.

The cause was the spec answering one question. spec.parse answers the
scanner's — "what am I searching for?" — and for an IDA pattern that is the
pattern text, which is not a value any address can hold. A cheat entry is
read and written every poll tick, so its type has to round-trip:
format(bytes read) must parse back to the same bytes.

The specs answer both questions now. ValueTypeSpec.parse_write turns cell text
into the bytes to write and receives the value last read at the address;
parse_value_for_write picks it when a spec has one. The scanner keeps asking
parse_value — it only ever searches — and the cheat table only ever writes, so
it no longer imports parse_value at all.

For an IDA pattern the tokens resolve to the bytes they name, and ? keeps the
byte already at that offset:

cell shows 48 8B 12 34 00
edit "48 8B 90 90 00" → writes 48 8B 90 90 00
edit "48 8B ? ? 90" → writes 48 8B 12 34 90 (the ?s are preserved)

That mirrors the wildcard's search meaning and is what makes a signature
patchable — you name the bytes you mean to change and leave the operands alone,
the same semantics Cheat Engine uses. A wildcard with nothing read yet is
refused with a message rather than writing a zero. A regex names a set of byte
strings rather than one, so there the cell text is taken literally, matching what
the cell displays. tokenize_pattern comes out of compile_pattern so the
search and write paths share one set of token rules and error messages.

Writing a value must not resize the entry that holds it, either. parse_value
honours an explicit length override, so before this no value edit ever changed
an entry's width; the new write path returned len(value) unconditionally, and
Regex does accept an override, so bulk-editing a 64-byte regex entry to "hi"
collapsed it to 2 bytes and the row displayed 2 bytes of the address from then
on. The write path follows the same override rule as the search path now.

Neighbouring, same cause: every path that fell back to the AOB spec's declared
length of 0 — promotion, adding an address by hand, changing an entry's type,
importing a JSON table — minted a zero-width entry that read back empty on every
tick. add_entry floors the width, since it is the one door all of those pass
through.

The pointer dialogs keep the regex type

Both dialogs read a value at an address the user already has, and both filtered
their "Read value as" list by is_pattern — which lumps together two specs that
behave nothing alike once the address is known.

An IDA pattern genuinely can't be offered there: it declares a length of 0 (the
scanner derives a match's width from the pattern) and refuses a length
override, so the dialog read zero bytes and displayed nothing. A regex has
neither problem — its width comes from the Length field, and it renders bytes as
text up to the first NUL, which String (UTF-8) does not:

memory: b"Player42\0\xff\xfe\x01rest"
Regex (String) → "Player42"
String (UTF-8) → "Player42\0\ufffd\ufffd\x01rest"

So it isn't a duplicate of anything else in the list. has_readable_width()
replaces the is_pattern test and asks what actually matters — can this spec
size a read at an address I already have? — derived from the spec rather than
from a label. Pointer Scan had the old filter before this branch and Pointer
Chain gained it during it; both were dropping the regex for the same wrong
reason, so both are corrected. The cheat table keeps offering both types, since
an entry carries its own width.

Comparisons that never worked

Increased/Decreased Value By on String / Byte Array: the comparator does
prev + exp, which concatenates rather than adds and is never equal to the
fixed-width value read back, while prev - exp raises TypeError that the
refine worker swallows into "doesn't match". Every address was dropped in
silence. The combination is rejected with a message pointing at Changed /
Unchanged Value. An empty String value likewise sized to a 1-byte NUL
buffer, so scanning for it matched every zeroed byte in the target; Byte Array
already rejected its own empty input.

Checklist (complete all items):

  • Added tests as necessary.
  • There is no breaking change for existing features.

References:

Closes#79

Notes:

Reproduction of the reported bug, and the same call after the fix:

build_scan_request(BYTES, EXACT_VALUE, value_text='00 11 22 AA BB CC', length_spin_value=4)
# before → length=4 → value_to_bytes(...) → ValueError: byte string too long# after → length=6 → value_to_bytes(...) → b'\x00\x11"\xaa\xbb\xcc'

Deliberately left alone: for VALUE_BETWEEN with endpoints of different widths
the shorter one is still NUL-padded up to the wider. That is documented library
behaviour (resolve_bufflength_for_value), it is what makes a fixed-width
comparison possible when the user gives two different widths, and str has
always behaved the same way.

Suite goes from 486 to 520 passing with no new failures; make lint is clean and
make type-check reports the same pre-existing errors as main (none in the
files touched). The 15 still-failing tests are unrelated to this branch: they are the macOS
KERN_MEMORY_ERROR scan abort fixed by #88, and they fail the same way on
main. With both branches merged locally the whole suite passes — 538 tests,
no failures — which is the first time the real memory read/write path has been
exercised here at all.

Scanning for a Byte Array (Hex) required the user to keep the Length
field in sync with the bytes they typed, and got both failure modes
wrong when they didn't:
* a value wider than Length was squeezed into a fixed-width ctypes
buffer and the scan died with "ValueError: byte string too long"
(the default Length is 4, so any 5-byte value hit this);
* a value narrower than Length was NUL-padded, silently turning the
scan into "these bytes followed by zeros" with no feedback.
String (UTF-8) already derived its width from the typed text, and the
library itself infers it in resolve_bufflength_for_value — the scanner
panel was the only place still asking. Byte Array now follows the same
rule: the Length field becomes a read-only readout of the parsed byte
count, and build_scan_request lets parse_value size the buffer. Partial
matching keeps its own value type (AOB Pattern).
This also fixes promoting byte-array results to the cheat table, which
read the same spin box and so created entries truncated to 4 bytes.
The no-value comparisons (Increased / Changed / …) still read the
Length field: they carry no value to measure, and the readout holds the
width the previous scan settled on.
Closes#79
@github-actionsgithub-actionsBot added app GUI application changes (PyMemoryEditor/app/) lib Library changes (PyMemoryEditor/) tests Test changes (tests/) labels Aug 27, 2026
Two problems in the same neighbourhood, surfaced by review of the
byte-array fix.
The no-value comparisons (Increased / Changed / …) carry no value to
measure, so they read the Length field — but str was excluded and fell
back to the spec default of 16. Picking "Changed Value" after a 4-byte
"olá" scan refined it by re-reading 16 bytes per address, so the value
read back ("olá\0\0…") never equalled the "olá" the first scan had
recorded and every address reported as Changed. The readout now holds
its last valid width when the value field is cleared (it already did
for byte arrays), and the no-value branch honours it for both types.
Promoting such a row to the cheat table was hitting the same 1-byte
readout and is fixed with it.
Switching value type left the readout showing a width computed for the
previous type when the value doesn't parse as the new one — 21 bytes of
string carried over into Byte Array, for instance. That field is
read-only now, so the user had no way to correct it. Selecting a
value-sized type seeds the spec default before syncing.
Follow-up to #79.
Opening the app lands on Int32, whose Length reads 4. Picking Byte
Array (Hex) kept showing that 4 — it was never a width the byte-array
scan would use — and the first hex digit typed dropped it to 1, so the
one number the field ever showed on its own was wrong.
String and Byte Array take their width from the value, and the field is
read-only for them, so there is nothing to show and no way for the user
to correct whatever we invent. The spin now opens a 0 slot rendered as
"— (set by the value)" for that state, and fills in a real number as
soon as the value sizes to one. The two consumers that could see the 0
(the no-value next-scan width and the promote-to-cheat-table length)
fall back to the spec default.
Selecting any other type clears the special-value text and restores the
minimum of 1, so a 1-byte Int8 shows "1 bytes" rather than landing on
the empty-slot rendering.
Follow-up to #79.
…ded with
Increased / Decreased / Changed / Unchanged compare against the values
the previous scan recorded, so they have to re-read at that scan's
width. They were reading the Length field instead, which tracks the
value currently in the Value box — and the user can edit that between
scans without rescanning. First Scan for the byte array `00 11`, type
`00 11 22 33`, pick "Changed Value", Next Scan: every address is
re-read 4 bytes wide, compared against a 2-byte baseline, and reported
as Changed. "Update Values" was worse, writing the mismatched reads
back over the baseline for every later comparison.
The panel now records the width of each scan it emits and passes it as
previous_scan_length; it's dropped along with the results it describes.
The Length readout no longer has to double as that memory, so it goes
back to being exactly what the current value sizes to — which also
fixes it claiming "4 bytes" for a String whose value box had been
cleared while the request was built at length 1.
Two neighbouring bugs on the same paths:
* promoting an AOB hit to the cheat table created a zero-width entry
(no Length field, and the IDA spec's length is 0), re-read as empty on
every poll tick. It now measures the pattern — one token, one byte;
* an empty String value parsed to a 1-byte NUL buffer, so scanning it
matched every zeroed byte in the target. Byte Array already rejected
its own empty input; both now do.
Follow-up to #79.
…ade them
Third round of the same mistake, in the paths the previous fix didn't
reach. All four take the width from the Length readout, which follows
the Value box — a no-value scan type clears that box outright, and the
user can retype it between scans without rescanning:
* promoting to the cheat table fell through to the spec default: a
4-byte "olá" scan promoted at 16, so the entry pulled 12 bytes of
neighbouring memory into the cell on every poll tick;
* "Increased/Decreased value BY" sit outside NO_VALUE_SCAN_TYPES, so
they missed the previous fix and sized the re-read from the *delta*
text — a 4-byte baseline re-read 1 byte at a time, dropping every
address, and then written back as the new baseline width;
* "Update Values" is a read-only refresh, but re-read at whatever was
in the Value box and moved the baseline to match, overwriting every
stored value with a truncated read;
* the readout ignored the range upper bound while the scan sized with
max(lo, hi), so a "AA".."AA BB CC" range reported 1 byte and ran at 3.
All four now use the width the results were scanned at. _sync_value_length
reads both bounds and no longer needs its callers to check the type.
Also: re-picking "AOB Pattern (IDA)" on a promoted hit reset the entry
to a zero-width buffer (the spec's own length is 0), undoing the pattern
width from the other side; and the empty-value message is neutral now
that it surfaces in the cheat table's write dialogs too.
Follow-up to #79.
…ng it
Fourth round on the same class, so this one names the concept instead of
patching another site: is_sized_by_value(spec) is now a single predicate
the panel uses everywhere it previously spelled out "String or Byte
Array, but not a pattern" by hand — which is what kept producing these.
That gate was wrong in one place already: accepts_length_override is
True for Regex too, so the width override caught it and made its Length
field — the only editable one, and the user's own byte_length — inert
after a first scan. Update Values and promote honour it again.
Update Values was building a full request just to throw the value away
(RefineScanWorker applies no comparison when filter_only is False). With
the new empty-value rejection that turned into a hard regression: a
Value box cleared by a no-value scan type aborted the refresh with
"Invalid value" instead of refreshing. It builds the request directly
now and can't fail.
And the baseline width is adopted when a scan *lands*, not when it is
dispatched: set_has_results is called by the owner on completion, so a
Next Scan whose worker errors out no longer leaves a width the values on
screen were never read at — which the following Changed/Unchanged refine
would have compared against.
Also: bulk edit collapsed AOB entries to a zero-width buffer exactly as
_change_type did before the last commit, and manually adding an AOB
address created one from the start; both ask for or keep a real width.
Follow-up to #79.
Chasing the zero-width AOB entry one call site at a time found two more:
importing a cheat table whose rows were saved by a build that promoted
AOB hits at the spec's 0 (`raw.get("length") or spec.length` is `0 or 0`),
and the pointer-chain dialog, which — unlike the pointer-scan dialog
right next to it — offered the pattern types in its "Read value as"
combo, so `_current_spec()` handed back the spec's 0.
Rather than a fourth guard, the floor now sits in add_entry: every entry
enters the table through it, whichever way it was made — promoted from a
scan, from either pointer dialog, added by hand, or loaded from JSON.
A zero-width entry can't be spotted from the table (the Value column
just reads back empty forever), so this is worth enforcing once at the
boundary rather than trusting each producer.
The pointer-chain dialog also gets the filter its sibling already had,
with the same reasoning: reading a value "as a pattern" is meaningless
when the address is already known.
Follow-up to #79.
…r worked
Last round's baseline fix promoted a pending width on any report of
results, and I reasoned that only a completed scan could produce one.
That was wrong: "Update Values" finishes through the same handler. So a
Next Scan whose worker errored left its width behind, and the next plain
refresh adopted it — 2-byte values on screen, baseline claiming 4, and
the following Changed Value refine drops everything. The pending width is
now discarded when the scan cycle ends (set_busy(False), which the owner
emits after the completion signal), so only the scan that actually landed
can have set it.
"Increased/Decreased Value By" never worked on String / Byte Array: the
comparator does `prev + exp`, which concatenates rather than adds and is
never equal to the fixed-width value read back, while `prev - exp` raises
TypeError that the refine worker swallows into "doesn't match". Every
address was dropped and nothing said why. The combination is rejected
with a message pointing at Changed / Unchanged Value, and last round's
width branch for it goes away — it was sizing a comparison that could
never be true.
Also: the cheat table's "Change buffer length" dialog capped at 1024
while a promoted String / Byte Array entry can be as wide as the value
scanned for, so opening it and pressing OK silently shrank the entry.
Follow-up to #79.
… edges
Giving AOB entries a real width made their Value cell live for the first
time, and it was wired backwards: the cell formats the bytes it reads as
hex, but parses an edit as a *pattern*, so typing "00" wrote ASCII
0x30 0x30 rather than the byte 0x00 — reported as a successful write. A
pattern finds an address; it can't hold a value. Hits promote as Byte
Array at the pattern's width now, so the cell reads and writes the same
thing. The zero-width buffer used to make every such write fail, which
is why this never showed.
Two more edges on the pending-width adoption:
* a cancelled refine still reports results (the worker breaks out of its
loop and emits finished_ok with what it kept), but only the rows it
reached were re-read at the new width. Neither width describes the
table, so cancelling keeps the one most rows were read at;
* a request the owner rejects before the busy cycle begins (its handler
returns early) never reaches set_busy(False), so its width lingered.
"Update Values" now records the width it re-reads at — which is what
the table will hold once it lands, and overwrites anything stale.
Also: the Length field's enable expression still tested a clause that
can no longer be true, reading as if more types than Regex could make it
editable. Regex is the only spec whose width is genuinely the user's.
Follow-up to #79.
Self-review of the whole diff, on the final state rather than commit by
commit. Three things it turned up.
The write-ASCII bug wasn't fixed, only patched at one of its three
doors. Substituting Byte Array inside current_spec_and_length covered
promotion, but adding an address by hand with the AOB type, changing an
existing entry's type to AOB, and importing a JSON table written before
that change all still produced a pattern-typed entry — whose cell
displays hex and writes the ASCII spelling of what you type. A pattern
is search syntax: a way to find an address, never a shape a value is
read and written back as. The type pickers now offer HOLDABLE_TYPE_LABELS
(the non-pattern specs, the same filter the pointer dialogs already use)
and add_entry re-types anything that still arrives — it is the one door
promote, manual add and JSON import all pass through. The substitution
comes out of current_spec_and_length, which was also handing the wrong
spec to "Update Values"; three now-unreachable `or`-guards for the AOB
zero come out with it, since they implied a zero could still arrive.
The delta rejection sat before the pattern short-circuit. Both pattern
specs are bytes-typed, so an AOB scan with a delta type raised instead
of forcing EXACT as the pattern path documents — and pointed the user at
Changed/Unchanged Value, which are disabled in pattern mode. Moved
below, where it means what it says.
Also: "Update Values" carried two comments contradicting each other
about whether it moves the baseline (it does, to the width it re-reads
at), and the Length field's enable rule had two overlapping rationales
left over from when the expression was more than `is_regex`.
Follow-up to #79.
Reverts removing AOB / Regex from the cheat table in favour of fixing
what was actually broken about them.
A cheat entry is read and written every poll tick, so its type has to
round-trip: format(bytes read) must parse back to the same bytes. The
pattern specs failed that because `parse` answers a different question —
"what am I searching for?" — and for an IDA pattern it hands back the
pattern *text*, so a cell displaying `48 8B 00` wrote the ASCII spelling
of that hex.
The specs now answer both questions. `ValueTypeSpec.parse_write` turns
cell text into the bytes to write, and takes the value last read at the
address; `parse_value_for_write` picks it when a spec has one. The
scanner keeps asking `parse_value` — it only ever searches — and the
cheat table only ever writes, so it no longer imports `parse_value` at
all.
For an IDA pattern that means the tokens resolve to the bytes they name,
and `?` keeps the byte already at that offset: `48 8B ? ? 90` over
`48 8B 12 34 00` writes `48 8B 12 34 90`. That mirrors the wildcard's
search meaning and is what makes a signature patchable — you name the
bytes you mean to change and leave the operands alone. A wildcard with
nothing read yet is refused with a message rather than writing a zero.
For a regex, which names a set of byte strings rather than one, the cell
text is taken literally, matching what the cell displays.
`tokenize_pattern` comes out of `compile_pattern` so the search and write
paths share one set of token rules and error messages.
Follow-up to #79.
Self-review of the parse_write commit. The cheat table's bulk edit
stores the width parse_value_for_write returns back onto the entry, and
that width is how many bytes the row *reads* on every poll tick — set by
the user, and none of a write's business.
parse_value honours an explicit length_override for the types that
accept one, so before parse_write existed no value edit ever resized an
entry. The new path returned len(value) unconditionally, and Regex does
accept an override: bulk-editing a 64-byte regex entry to "hi" collapsed
it to 2 bytes, so the row then displayed 2 bytes of the address forever.
It now follows the same override rule as parse_value.
An IDA pattern is unaffected either way — it declines a length override,
so the cheat table never writes the returned width onto the entry — but
it now reports the pattern's own byte count rather than the search
path's 0, which is not a size any buffer could have.
Both pointer dialogs filtered their "Read value as" list by is_pattern,
which lumps together two specs that behave nothing alike once the
address is already known.
An IDA pattern genuinely can't be offered: it declares a length of 0 —
the scanner derives a match's width from the pattern — and refuses a
length override, so the dialog read zero bytes and displayed nothing. A
regex has neither problem: its width comes from the Length field, and it
renders the bytes as text up to the first NUL, which "String (UTF-8)"
does not. Reading b"Player42\0\xff\xfe\x01rest" shows "Player42" as a
regex and "Player42\0��\x01rest" as a string, so it isn't a
duplicate of anything else in the list.
has_readable_width() replaces the is_pattern test and asks the question
that actually matters — can this spec size a read at an address I
already have? — derived from the spec rather than from a label. The
cheat table keeps offering both, since an entry carries its own width.
Pointer Scan had this filter before the branch and Pointer Chain gained
it earlier in it; both were dropping the regex for the same wrong
reason, so both are corrected.
An entry's last_value / frozen_value hold whatever the *previous* spec
decoded, and nothing waits for a fresh poll tick after the type changes,
so the new spec was being applied to the old type's value. Two ways out:
* _rebuild formats it — _fmt_bytes(1234) raises TypeError from inside a
Qt slot, so an Int32 row retyped to Byte Array or AOB left the table
un-rebuilt; a frozen entry would also republish the old value to the
poll worker under the new pytype;
* the bulk edit's "set type" and "set value" run in the same pass, so an
IDA pattern's wildcard reached len() on an int. That raised TypeError
too, which neither call site's `except ValueError` catches.
Both branches now forget the cached value when the label actually
changes — the next tick refills it — and the pattern write path treats a
non-bytes `current` as "nothing read yet", so it stays inside the
ValueError contract its callers handle.
Cancelling a *first* scan no longer discards its width. Both workers
report partial results after a cancel, but only a refine leaves the rows
at mixed widths; every address a first scan found, it found at its own
width, and there is no earlier one to fall back on — so the next
Changed/Unchanged refine went to the spec default, 16 for a String
scanned at 4, which is the failure this branch exists to fix.
_has_results tells the two apart.
Also: an IDA pattern wider than the entry it is typed into was truncated
by prepare_write without a word, while the same edit one byte short was
refused by the wildcard check; it is refused now too. And
current_spec_and_length still documented a re-typing that was reverted
several commits ago.
tokenize_pattern comes back out of util.pattern.__all__ — it exists so
the scan and write paths share token rules, nothing outside the package
consumes it, and neither the guide nor the API reference describes it.
Follow-ups from review of the previous commit, three of them consequences
of it.
Dropping frozen_value on a type change left `frozen` ticked with no
baseline, and nothing re-adopted one: the poll worker skips a frozen
entry whose frozen_value is None, so the Active box stayed on while the
freeze silently never wrote again until the user toggled it. The next
tick now adopts the first value read under the new type.
A value wider than its entry was written short without a word — the cell
kept showing all of it until the next tick corrected itself — because
prepare_write treats the entry width as a hard truncating cap. The
previous commit added exactly this guard for patterns and left String
and Byte Array silently dropping the tail; all three refuse it now.
An IDA pattern's promotion width was re-derived from the Value box,
which stays editable in pattern mode, so editing the pattern after a
scan promoted (or refreshed) at a width the hits were never found at —
the same staleness _last_scan_length exists to prevent. The pattern path
couldn't use it because a pattern request reports a length of 0, which
never promoted; the dispatched width is now the token count.
Also: the wildcard's out-of-range message read as though the entry were
permanently too narrow, when what it measures is the last read, which a
narrower write shortens until the next tick.
…ress
Three misses from the same habit — fixing a case instead of the rule.
add_entry's "address already exists" branch is the third place a
spec_label changes, and the previous commit fixed the other two. Promote
an address already in the table under a different type — from a scan or
from either pointer dialog — and the value the old spec decoded survived
into _rebuild, where the new spec's formatter met it:
_fmt_bytes("hello") raises ValueError from inside add_entry itself.
The width guard was in two places and wrong in both. On the parse_write
path it existed only for IDA patterns, so a regex entry got exactly the
silent truncation the guard was added to stop. And it counted len(value)
— characters for str — against a width that is a byte count, so "ábc"
(3 characters, 4 bytes) passed the check for a 3-byte entry and
prepare_write put a byte through the far wall. There is now one guard,
after the value exists, measuring what actually goes on the wire; it
covers String, Byte Array, Regex and IDA patterns alike.
Not changed: current_spec_and_length still reads the live Length field
for a regex rather than _last_scan_length. That field is the user's
byte_length and the only editable width in the app; an earlier review
flagged the opposite — that preferring the scan width left it inert —
and Next Scan is disabled in pattern mode, so no baseline comparison can
be thrown off by it. Editing it and refreshing is the control working.
Three from review of the previous commit.
Changing a row's type and writing a value in the same bulk edit failed
every selected row: the type change forgets the cached value, and the
write in the same iteration then had nothing for an IDA '?' to keep —
"can't be used before the value has been read at least once", about
bytes that were sitting right there. Byte Array → AOB doesn't change
what those bytes mean, and doing the two steps separately worked, which
is the tell. The write now captures the value before the forget runs.
Forgetting stays unconditional: a switch that keeps the pytype can still
change the width (Int32 → Int16), and parse_value_for_write already
ignores a `current` the new type could not have produced.
"Change buffer length" capped at max(1024, entry.length), so an entry
already wider than 1024 — which is normal now that entries are promoted
at the width of the value scanned for — could only ever be shrunk, while
a wider value couldn't be written into it either, since the width guard
refuses that. No way out from inside the app. It uses the same ceiling
the scanner does.
And the Type column showed the width only for specs that accept a length
override, which excludes the IDA pattern — the one spec whose width this
branch made real and enforced. Being told to widen an entry whose width
is nowhere on screen isn't much of an instruction.
Three dead ends the previous commit's guards created, all found by
review of it.
A bulk edit that retypes rows to a wider type refused every one of them.
Three Int32 rows retyped to String with "hello" hit "the entry holds 4 —
widen the entry first", and there is no width field in the bulk dialog,
nor a "change length" on a multi-row selection: no way out from inside
the app. A retype to a variable-width spec now takes the width from the
value it writes, which is what promotion does too.
The width ceiling went from a too-tight 1024 to no bound at all, and the
poll worker allocates the entry's width for every row on every 100 ms
tick — one typo away from a 2 GB allocation the tick's blanket except
swallows and retries forever. MAX_ENTRY_LENGTH bounds it at a megabyte,
far past any value a scan produces, and the manual-add dialog uses the
same number instead of disagreeing at 1024.
Re-arming a frozen row on the next tick after a type change traded a
no-op for a wrong write: with frozen_value gone and the box still
ticked, the tick adopted whatever the address happened to hold and the
app started pinning a value the user never chose. The type change
releases the freeze now. The re-arm stays for the case it was written
for — a box ticked before the first read, which is a deliberate action
on an unchanged type.
Not changed: a regex cell writes its text literally, so editing one that
held non-UTF-8 bytes writes U+FFFD back and the old tail survives past
the new text. That is how String (UTF-8) has always behaved — reads
decode with errors="replace" and prepare_write never pads — so it is a
property of writing text over binary, not of making regex writable.
Changing it means changing what a string write means.
@JeanExtreme002
JeanExtreme002 merged commit 7c7f80c into mainAug 28, 2026
13 checks passed
@github-actions
github-actionsBot deleted the fix/byte-array-infers-length branch August 28, 2026 20:50
@JeanExtreme002JeanExtreme002 mentioned this pull request Aug 28, 2026
2 tasks
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

appGUI application changes (PyMemoryEditor/app/)libLibrary changes (PyMemoryEditor/)testsTest changes (tests/)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Byte array search shouldn't need to specify length

1 participant

@JeanExtreme002
, '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

fix(app): infer the byte-array scan width from the value entered - #87

Merged
JeanExtreme002 merged 19 commits into
mainfrom
fix/byte-array-infers-length
Aug 28, 2026
Merged

fix(app): infer the byte-array scan width from the value entered#87
JeanExtreme002 merged 19 commits into
mainfrom
fix/byte-array-infers-length

Conversation

@JeanExtreme002

@JeanExtreme002JeanExtreme002 commented Aug 27, 2026

Copy link
Copy Markdown
Owner

Why is this PR necessary, what does it do?

Fixes#79, and the run of related width bugs that surfaced while doing it.

The reported bug

Scanning for a Byte Array (Hex) required the user to keep the Length field
in sync by hand with the bytes they typed, and got both failure modes wrong:

  • a value wider than Length was squeezed into a fixed-width ctypes buffer
    and the scan died with ValueError: byte string too long. The default Length
    is 4, so any 5-byte value hit this — exactly what Byte array search shouldn't need to specify length #79 reports;
  • a value narrower than Length was NUL-padded, silently turning the scan
    into "these bytes followed by zeros": no error, just a scan that finds
    nothing and never says why.

String (UTF-8) already derived its width from the typed text, and the library
itself infers it in resolve_bufflength_for_value — the scanner panel was the
only place still asking the user to count. Byte Array now follows the same rule,
and the Length field became a read-only readout of what the value sizes to.
Partial matching keeps its own value type (AOB Pattern (IDA)).

One width, one source

Fixing that exposed the same mistake in every other place that needed "the width
of the rows currently on screen" and read the Length field instead — which
follows the Value box, and the user can retype that between scans:

  • promote to cheat table created entries at the wrong width (a 4-byte olá
    scan promoted at the spec's 16, so the cell pulled 12 bytes of neighbouring
    memory in on every poll tick);
  • the no-value comparisons (Changed / Unchanged / Increased / Decreased)
    re-read at a width the baseline was never recorded at, so every address
    reported as changed;
  • Update Values re-read at whatever was in the Value box, overwriting every
    stored value with a truncated read — and aborted outright with
    "Invalid value" if that box happened to be empty;
  • the readout ignored a range's upper bound while the scan sized with
    max(lo, hi).

They all take it from _last_scan_length now — the width of the scan that
produced the rows — which is adopted when a scan lands, not when it is
dispatched, so one that errors, is cancelled, or is rejected by its handler
can't leave a width behind for a later refresh to pick up. is_sized_by_value()
replaced the "String or Byte Array, but not a pattern" test that was being
spelled out by hand at each site, which is what kept producing these.

Pattern types now round-trip as cheat entries

Giving AOB entries a real width made their cheat-table cell editable for the
first time, and it was wired backwards: the cell formats what it reads as hex
but parsed an edit as a pattern, so typing 00 wrote ASCII 0x30 0x30 into
the target and reported success.

The cause was the spec answering one question. spec.parse answers the
scanner's — "what am I searching for?" — and for an IDA pattern that is the
pattern text, which is not a value any address can hold. A cheat entry is
read and written every poll tick, so its type has to round-trip:
format(bytes read) must parse back to the same bytes.

The specs answer both questions now. ValueTypeSpec.parse_write turns cell text
into the bytes to write and receives the value last read at the address;
parse_value_for_write picks it when a spec has one. The scanner keeps asking
parse_value — it only ever searches — and the cheat table only ever writes, so
it no longer imports parse_value at all.

For an IDA pattern the tokens resolve to the bytes they name, and ? keeps the
byte already at that offset:

cell shows 48 8B 12 34 00
edit "48 8B 90 90 00" → writes 48 8B 90 90 00
edit "48 8B ? ? 90" → writes 48 8B 12 34 90 (the ?s are preserved)

That mirrors the wildcard's search meaning and is what makes a signature
patchable — you name the bytes you mean to change and leave the operands alone,
the same semantics Cheat Engine uses. A wildcard with nothing read yet is
refused with a message rather than writing a zero. A regex names a set of byte
strings rather than one, so there the cell text is taken literally, matching what
the cell displays. tokenize_pattern comes out of compile_pattern so the
search and write paths share one set of token rules and error messages.

Writing a value must not resize the entry that holds it, either. parse_value
honours an explicit length override, so before this no value edit ever changed
an entry's width; the new write path returned len(value) unconditionally, and
Regex does accept an override, so bulk-editing a 64-byte regex entry to "hi"
collapsed it to 2 bytes and the row displayed 2 bytes of the address from then
on. The write path follows the same override rule as the search path now.

Neighbouring, same cause: every path that fell back to the AOB spec's declared
length of 0 — promotion, adding an address by hand, changing an entry's type,
importing a JSON table — minted a zero-width entry that read back empty on every
tick. add_entry floors the width, since it is the one door all of those pass
through.

The pointer dialogs keep the regex type

Both dialogs read a value at an address the user already has, and both filtered
their "Read value as" list by is_pattern — which lumps together two specs that
behave nothing alike once the address is known.

An IDA pattern genuinely can't be offered there: it declares a length of 0 (the
scanner derives a match's width from the pattern) and refuses a length
override, so the dialog read zero bytes and displayed nothing. A regex has
neither problem — its width comes from the Length field, and it renders bytes as
text up to the first NUL, which String (UTF-8) does not:

memory: b"Player42\0\xff\xfe\x01rest"
Regex (String) → "Player42"
String (UTF-8) → "Player42\0\ufffd\ufffd\x01rest"

So it isn't a duplicate of anything else in the list. has_readable_width()
replaces the is_pattern test and asks what actually matters — can this spec
size a read at an address I already have? — derived from the spec rather than
from a label. Pointer Scan had the old filter before this branch and Pointer
Chain gained it during it; both were dropping the regex for the same wrong
reason, so both are corrected. The cheat table keeps offering both types, since
an entry carries its own width.

Comparisons that never worked

Increased/Decreased Value By on String / Byte Array: the comparator does
prev + exp, which concatenates rather than adds and is never equal to the
fixed-width value read back, while prev - exp raises TypeError that the
refine worker swallows into "doesn't match". Every address was dropped in
silence. The combination is rejected with a message pointing at Changed /
Unchanged Value. An empty String value likewise sized to a 1-byte NUL
buffer, so scanning for it matched every zeroed byte in the target; Byte Array
already rejected its own empty input.

Checklist (complete all items):

  • Added tests as necessary.
  • There is no breaking change for existing features.

References:

Closes#79

Notes:

Reproduction of the reported bug, and the same call after the fix:

build_scan_request(BYTES, EXACT_VALUE, value_text='00 11 22 AA BB CC', length_spin_value=4)
# before → length=4 → value_to_bytes(...) → ValueError: byte string too long# after → length=6 → value_to_bytes(...) → b'\x00\x11"\xaa\xbb\xcc'

Deliberately left alone: for VALUE_BETWEEN with endpoints of different widths
the shorter one is still NUL-padded up to the wider. That is documented library
behaviour (resolve_bufflength_for_value), it is what makes a fixed-width
comparison possible when the user gives two different widths, and str has
always behaved the same way.

Suite goes from 486 to 520 passing with no new failures; make lint is clean and
make type-check reports the same pre-existing errors as main (none in the
files touched). The 15 still-failing tests are unrelated to this branch: they are the macOS
KERN_MEMORY_ERROR scan abort fixed by #88, and they fail the same way on
main. With both branches merged locally the whole suite passes — 538 tests,
no failures — which is the first time the real memory read/write path has been
exercised here at all.

Scanning for a Byte Array (Hex) required the user to keep the Length
field in sync with the bytes they typed, and got both failure modes
wrong when they didn't:
* a value wider than Length was squeezed into a fixed-width ctypes
buffer and the scan died with "ValueError: byte string too long"
(the default Length is 4, so any 5-byte value hit this);
* a value narrower than Length was NUL-padded, silently turning the
scan into "these bytes followed by zeros" with no feedback.
String (UTF-8) already derived its width from the typed text, and the
library itself infers it in resolve_bufflength_for_value — the scanner
panel was the only place still asking. Byte Array now follows the same
rule: the Length field becomes a read-only readout of the parsed byte
count, and build_scan_request lets parse_value size the buffer. Partial
matching keeps its own value type (AOB Pattern).
This also fixes promoting byte-array results to the cheat table, which
read the same spin box and so created entries truncated to 4 bytes.
The no-value comparisons (Increased / Changed / …) still read the
Length field: they carry no value to measure, and the readout holds the
width the previous scan settled on.
Closes#79
@github-actionsgithub-actionsBot added app GUI application changes (PyMemoryEditor/app/) lib Library changes (PyMemoryEditor/) tests Test changes (tests/) labels Aug 27, 2026
Two problems in the same neighbourhood, surfaced by review of the
byte-array fix.
The no-value comparisons (Increased / Changed / …) carry no value to
measure, so they read the Length field — but str was excluded and fell
back to the spec default of 16. Picking "Changed Value" after a 4-byte
"olá" scan refined it by re-reading 16 bytes per address, so the value
read back ("olá\0\0…") never equalled the "olá" the first scan had
recorded and every address reported as Changed. The readout now holds
its last valid width when the value field is cleared (it already did
for byte arrays), and the no-value branch honours it for both types.
Promoting such a row to the cheat table was hitting the same 1-byte
readout and is fixed with it.
Switching value type left the readout showing a width computed for the
previous type when the value doesn't parse as the new one — 21 bytes of
string carried over into Byte Array, for instance. That field is
read-only now, so the user had no way to correct it. Selecting a
value-sized type seeds the spec default before syncing.
Follow-up to #79.
Opening the app lands on Int32, whose Length reads 4. Picking Byte
Array (Hex) kept showing that 4 — it was never a width the byte-array
scan would use — and the first hex digit typed dropped it to 1, so the
one number the field ever showed on its own was wrong.
String and Byte Array take their width from the value, and the field is
read-only for them, so there is nothing to show and no way for the user
to correct whatever we invent. The spin now opens a 0 slot rendered as
"— (set by the value)" for that state, and fills in a real number as
soon as the value sizes to one. The two consumers that could see the 0
(the no-value next-scan width and the promote-to-cheat-table length)
fall back to the spec default.
Selecting any other type clears the special-value text and restores the
minimum of 1, so a 1-byte Int8 shows "1 bytes" rather than landing on
the empty-slot rendering.
Follow-up to #79.
…ded with
Increased / Decreased / Changed / Unchanged compare against the values
the previous scan recorded, so they have to re-read at that scan's
width. They were reading the Length field instead, which tracks the
value currently in the Value box — and the user can edit that between
scans without rescanning. First Scan for the byte array `00 11`, type
`00 11 22 33`, pick "Changed Value", Next Scan: every address is
re-read 4 bytes wide, compared against a 2-byte baseline, and reported
as Changed. "Update Values" was worse, writing the mismatched reads
back over the baseline for every later comparison.
The panel now records the width of each scan it emits and passes it as
previous_scan_length; it's dropped along with the results it describes.
The Length readout no longer has to double as that memory, so it goes
back to being exactly what the current value sizes to — which also
fixes it claiming "4 bytes" for a String whose value box had been
cleared while the request was built at length 1.
Two neighbouring bugs on the same paths:
* promoting an AOB hit to the cheat table created a zero-width entry
(no Length field, and the IDA spec's length is 0), re-read as empty on
every poll tick. It now measures the pattern — one token, one byte;
* an empty String value parsed to a 1-byte NUL buffer, so scanning it
matched every zeroed byte in the target. Byte Array already rejected
its own empty input; both now do.
Follow-up to #79.
…ade them
Third round of the same mistake, in the paths the previous fix didn't
reach. All four take the width from the Length readout, which follows
the Value box — a no-value scan type clears that box outright, and the
user can retype it between scans without rescanning:
* promoting to the cheat table fell through to the spec default: a
4-byte "olá" scan promoted at 16, so the entry pulled 12 bytes of
neighbouring memory into the cell on every poll tick;
* "Increased/Decreased value BY" sit outside NO_VALUE_SCAN_TYPES, so
they missed the previous fix and sized the re-read from the *delta*
text — a 4-byte baseline re-read 1 byte at a time, dropping every
address, and then written back as the new baseline width;
* "Update Values" is a read-only refresh, but re-read at whatever was
in the Value box and moved the baseline to match, overwriting every
stored value with a truncated read;
* the readout ignored the range upper bound while the scan sized with
max(lo, hi), so a "AA".."AA BB CC" range reported 1 byte and ran at 3.
All four now use the width the results were scanned at. _sync_value_length
reads both bounds and no longer needs its callers to check the type.
Also: re-picking "AOB Pattern (IDA)" on a promoted hit reset the entry
to a zero-width buffer (the spec's own length is 0), undoing the pattern
width from the other side; and the empty-value message is neutral now
that it surfaces in the cheat table's write dialogs too.
Follow-up to #79.
…ng it
Fourth round on the same class, so this one names the concept instead of
patching another site: is_sized_by_value(spec) is now a single predicate
the panel uses everywhere it previously spelled out "String or Byte
Array, but not a pattern" by hand — which is what kept producing these.
That gate was wrong in one place already: accepts_length_override is
True for Regex too, so the width override caught it and made its Length
field — the only editable one, and the user's own byte_length — inert
after a first scan. Update Values and promote honour it again.
Update Values was building a full request just to throw the value away
(RefineScanWorker applies no comparison when filter_only is False). With
the new empty-value rejection that turned into a hard regression: a
Value box cleared by a no-value scan type aborted the refresh with
"Invalid value" instead of refreshing. It builds the request directly
now and can't fail.
And the baseline width is adopted when a scan *lands*, not when it is
dispatched: set_has_results is called by the owner on completion, so a
Next Scan whose worker errors out no longer leaves a width the values on
screen were never read at — which the following Changed/Unchanged refine
would have compared against.
Also: bulk edit collapsed AOB entries to a zero-width buffer exactly as
_change_type did before the last commit, and manually adding an AOB
address created one from the start; both ask for or keep a real width.
Follow-up to #79.
Chasing the zero-width AOB entry one call site at a time found two more:
importing a cheat table whose rows were saved by a build that promoted
AOB hits at the spec's 0 (`raw.get("length") or spec.length` is `0 or 0`),
and the pointer-chain dialog, which — unlike the pointer-scan dialog
right next to it — offered the pattern types in its "Read value as"
combo, so `_current_spec()` handed back the spec's 0.
Rather than a fourth guard, the floor now sits in add_entry: every entry
enters the table through it, whichever way it was made — promoted from a
scan, from either pointer dialog, added by hand, or loaded from JSON.
A zero-width entry can't be spotted from the table (the Value column
just reads back empty forever), so this is worth enforcing once at the
boundary rather than trusting each producer.
The pointer-chain dialog also gets the filter its sibling already had,
with the same reasoning: reading a value "as a pattern" is meaningless
when the address is already known.
Follow-up to #79.
…r worked
Last round's baseline fix promoted a pending width on any report of
results, and I reasoned that only a completed scan could produce one.
That was wrong: "Update Values" finishes through the same handler. So a
Next Scan whose worker errored left its width behind, and the next plain
refresh adopted it — 2-byte values on screen, baseline claiming 4, and
the following Changed Value refine drops everything. The pending width is
now discarded when the scan cycle ends (set_busy(False), which the owner
emits after the completion signal), so only the scan that actually landed
can have set it.
"Increased/Decreased Value By" never worked on String / Byte Array: the
comparator does `prev + exp`, which concatenates rather than adds and is
never equal to the fixed-width value read back, while `prev - exp` raises
TypeError that the refine worker swallows into "doesn't match". Every
address was dropped and nothing said why. The combination is rejected
with a message pointing at Changed / Unchanged Value, and last round's
width branch for it goes away — it was sizing a comparison that could
never be true.
Also: the cheat table's "Change buffer length" dialog capped at 1024
while a promoted String / Byte Array entry can be as wide as the value
scanned for, so opening it and pressing OK silently shrank the entry.
Follow-up to #79.
… edges
Giving AOB entries a real width made their Value cell live for the first
time, and it was wired backwards: the cell formats the bytes it reads as
hex, but parses an edit as a *pattern*, so typing "00" wrote ASCII
0x30 0x30 rather than the byte 0x00 — reported as a successful write. A
pattern finds an address; it can't hold a value. Hits promote as Byte
Array at the pattern's width now, so the cell reads and writes the same
thing. The zero-width buffer used to make every such write fail, which
is why this never showed.
Two more edges on the pending-width adoption:
* a cancelled refine still reports results (the worker breaks out of its
loop and emits finished_ok with what it kept), but only the rows it
reached were re-read at the new width. Neither width describes the
table, so cancelling keeps the one most rows were read at;
* a request the owner rejects before the busy cycle begins (its handler
returns early) never reaches set_busy(False), so its width lingered.
"Update Values" now records the width it re-reads at — which is what
the table will hold once it lands, and overwrites anything stale.
Also: the Length field's enable expression still tested a clause that
can no longer be true, reading as if more types than Regex could make it
editable. Regex is the only spec whose width is genuinely the user's.
Follow-up to #79.
Self-review of the whole diff, on the final state rather than commit by
commit. Three things it turned up.
The write-ASCII bug wasn't fixed, only patched at one of its three
doors. Substituting Byte Array inside current_spec_and_length covered
promotion, but adding an address by hand with the AOB type, changing an
existing entry's type to AOB, and importing a JSON table written before
that change all still produced a pattern-typed entry — whose cell
displays hex and writes the ASCII spelling of what you type. A pattern
is search syntax: a way to find an address, never a shape a value is
read and written back as. The type pickers now offer HOLDABLE_TYPE_LABELS
(the non-pattern specs, the same filter the pointer dialogs already use)
and add_entry re-types anything that still arrives — it is the one door
promote, manual add and JSON import all pass through. The substitution
comes out of current_spec_and_length, which was also handing the wrong
spec to "Update Values"; three now-unreachable `or`-guards for the AOB
zero come out with it, since they implied a zero could still arrive.
The delta rejection sat before the pattern short-circuit. Both pattern
specs are bytes-typed, so an AOB scan with a delta type raised instead
of forcing EXACT as the pattern path documents — and pointed the user at
Changed/Unchanged Value, which are disabled in pattern mode. Moved
below, where it means what it says.
Also: "Update Values" carried two comments contradicting each other
about whether it moves the baseline (it does, to the width it re-reads
at), and the Length field's enable rule had two overlapping rationales
left over from when the expression was more than `is_regex`.
Follow-up to #79.
Reverts removing AOB / Regex from the cheat table in favour of fixing
what was actually broken about them.
A cheat entry is read and written every poll tick, so its type has to
round-trip: format(bytes read) must parse back to the same bytes. The
pattern specs failed that because `parse` answers a different question —
"what am I searching for?" — and for an IDA pattern it hands back the
pattern *text*, so a cell displaying `48 8B 00` wrote the ASCII spelling
of that hex.
The specs now answer both questions. `ValueTypeSpec.parse_write` turns
cell text into the bytes to write, and takes the value last read at the
address; `parse_value_for_write` picks it when a spec has one. The
scanner keeps asking `parse_value` — it only ever searches — and the
cheat table only ever writes, so it no longer imports `parse_value` at
all.
For an IDA pattern that means the tokens resolve to the bytes they name,
and `?` keeps the byte already at that offset: `48 8B ? ? 90` over
`48 8B 12 34 00` writes `48 8B 12 34 90`. That mirrors the wildcard's
search meaning and is what makes a signature patchable — you name the
bytes you mean to change and leave the operands alone. A wildcard with
nothing read yet is refused with a message rather than writing a zero.
For a regex, which names a set of byte strings rather than one, the cell
text is taken literally, matching what the cell displays.
`tokenize_pattern` comes out of `compile_pattern` so the search and write
paths share one set of token rules and error messages.
Follow-up to #79.
Self-review of the parse_write commit. The cheat table's bulk edit
stores the width parse_value_for_write returns back onto the entry, and
that width is how many bytes the row *reads* on every poll tick — set by
the user, and none of a write's business.
parse_value honours an explicit length_override for the types that
accept one, so before parse_write existed no value edit ever resized an
entry. The new path returned len(value) unconditionally, and Regex does
accept an override: bulk-editing a 64-byte regex entry to "hi" collapsed
it to 2 bytes, so the row then displayed 2 bytes of the address forever.
It now follows the same override rule as parse_value.
An IDA pattern is unaffected either way — it declines a length override,
so the cheat table never writes the returned width onto the entry — but
it now reports the pattern's own byte count rather than the search
path's 0, which is not a size any buffer could have.
Both pointer dialogs filtered their "Read value as" list by is_pattern,
which lumps together two specs that behave nothing alike once the
address is already known.
An IDA pattern genuinely can't be offered: it declares a length of 0 —
the scanner derives a match's width from the pattern — and refuses a
length override, so the dialog read zero bytes and displayed nothing. A
regex has neither problem: its width comes from the Length field, and it
renders the bytes as text up to the first NUL, which "String (UTF-8)"
does not. Reading b"Player42\0\xff\xfe\x01rest" shows "Player42" as a
regex and "Player42\0��\x01rest" as a string, so it isn't a
duplicate of anything else in the list.
has_readable_width() replaces the is_pattern test and asks the question
that actually matters — can this spec size a read at an address I
already have? — derived from the spec rather than from a label. The
cheat table keeps offering both, since an entry carries its own width.
Pointer Scan had this filter before the branch and Pointer Chain gained
it earlier in it; both were dropping the regex for the same wrong
reason, so both are corrected.
An entry's last_value / frozen_value hold whatever the *previous* spec
decoded, and nothing waits for a fresh poll tick after the type changes,
so the new spec was being applied to the old type's value. Two ways out:
* _rebuild formats it — _fmt_bytes(1234) raises TypeError from inside a
Qt slot, so an Int32 row retyped to Byte Array or AOB left the table
un-rebuilt; a frozen entry would also republish the old value to the
poll worker under the new pytype;
* the bulk edit's "set type" and "set value" run in the same pass, so an
IDA pattern's wildcard reached len() on an int. That raised TypeError
too, which neither call site's `except ValueError` catches.
Both branches now forget the cached value when the label actually
changes — the next tick refills it — and the pattern write path treats a
non-bytes `current` as "nothing read yet", so it stays inside the
ValueError contract its callers handle.
Cancelling a *first* scan no longer discards its width. Both workers
report partial results after a cancel, but only a refine leaves the rows
at mixed widths; every address a first scan found, it found at its own
width, and there is no earlier one to fall back on — so the next
Changed/Unchanged refine went to the spec default, 16 for a String
scanned at 4, which is the failure this branch exists to fix.
_has_results tells the two apart.
Also: an IDA pattern wider than the entry it is typed into was truncated
by prepare_write without a word, while the same edit one byte short was
refused by the wildcard check; it is refused now too. And
current_spec_and_length still documented a re-typing that was reverted
several commits ago.
tokenize_pattern comes back out of util.pattern.__all__ — it exists so
the scan and write paths share token rules, nothing outside the package
consumes it, and neither the guide nor the API reference describes it.
Follow-ups from review of the previous commit, three of them consequences
of it.
Dropping frozen_value on a type change left `frozen` ticked with no
baseline, and nothing re-adopted one: the poll worker skips a frozen
entry whose frozen_value is None, so the Active box stayed on while the
freeze silently never wrote again until the user toggled it. The next
tick now adopts the first value read under the new type.
A value wider than its entry was written short without a word — the cell
kept showing all of it until the next tick corrected itself — because
prepare_write treats the entry width as a hard truncating cap. The
previous commit added exactly this guard for patterns and left String
and Byte Array silently dropping the tail; all three refuse it now.
An IDA pattern's promotion width was re-derived from the Value box,
which stays editable in pattern mode, so editing the pattern after a
scan promoted (or refreshed) at a width the hits were never found at —
the same staleness _last_scan_length exists to prevent. The pattern path
couldn't use it because a pattern request reports a length of 0, which
never promoted; the dispatched width is now the token count.
Also: the wildcard's out-of-range message read as though the entry were
permanently too narrow, when what it measures is the last read, which a
narrower write shortens until the next tick.
…ress
Three misses from the same habit — fixing a case instead of the rule.
add_entry's "address already exists" branch is the third place a
spec_label changes, and the previous commit fixed the other two. Promote
an address already in the table under a different type — from a scan or
from either pointer dialog — and the value the old spec decoded survived
into _rebuild, where the new spec's formatter met it:
_fmt_bytes("hello") raises ValueError from inside add_entry itself.
The width guard was in two places and wrong in both. On the parse_write
path it existed only for IDA patterns, so a regex entry got exactly the
silent truncation the guard was added to stop. And it counted len(value)
— characters for str — against a width that is a byte count, so "ábc"
(3 characters, 4 bytes) passed the check for a 3-byte entry and
prepare_write put a byte through the far wall. There is now one guard,
after the value exists, measuring what actually goes on the wire; it
covers String, Byte Array, Regex and IDA patterns alike.
Not changed: current_spec_and_length still reads the live Length field
for a regex rather than _last_scan_length. That field is the user's
byte_length and the only editable width in the app; an earlier review
flagged the opposite — that preferring the scan width left it inert —
and Next Scan is disabled in pattern mode, so no baseline comparison can
be thrown off by it. Editing it and refreshing is the control working.
Three from review of the previous commit.
Changing a row's type and writing a value in the same bulk edit failed
every selected row: the type change forgets the cached value, and the
write in the same iteration then had nothing for an IDA '?' to keep —
"can't be used before the value has been read at least once", about
bytes that were sitting right there. Byte Array → AOB doesn't change
what those bytes mean, and doing the two steps separately worked, which
is the tell. The write now captures the value before the forget runs.
Forgetting stays unconditional: a switch that keeps the pytype can still
change the width (Int32 → Int16), and parse_value_for_write already
ignores a `current` the new type could not have produced.
"Change buffer length" capped at max(1024, entry.length), so an entry
already wider than 1024 — which is normal now that entries are promoted
at the width of the value scanned for — could only ever be shrunk, while
a wider value couldn't be written into it either, since the width guard
refuses that. No way out from inside the app. It uses the same ceiling
the scanner does.
And the Type column showed the width only for specs that accept a length
override, which excludes the IDA pattern — the one spec whose width this
branch made real and enforced. Being told to widen an entry whose width
is nowhere on screen isn't much of an instruction.
Three dead ends the previous commit's guards created, all found by
review of it.
A bulk edit that retypes rows to a wider type refused every one of them.
Three Int32 rows retyped to String with "hello" hit "the entry holds 4 —
widen the entry first", and there is no width field in the bulk dialog,
nor a "change length" on a multi-row selection: no way out from inside
the app. A retype to a variable-width spec now takes the width from the
value it writes, which is what promotion does too.
The width ceiling went from a too-tight 1024 to no bound at all, and the
poll worker allocates the entry's width for every row on every 100 ms
tick — one typo away from a 2 GB allocation the tick's blanket except
swallows and retries forever. MAX_ENTRY_LENGTH bounds it at a megabyte,
far past any value a scan produces, and the manual-add dialog uses the
same number instead of disagreeing at 1024.
Re-arming a frozen row on the next tick after a type change traded a
no-op for a wrong write: with frozen_value gone and the box still
ticked, the tick adopted whatever the address happened to hold and the
app started pinning a value the user never chose. The type change
releases the freeze now. The re-arm stays for the case it was written
for — a box ticked before the first read, which is a deliberate action
on an unchanged type.
Not changed: a regex cell writes its text literally, so editing one that
held non-UTF-8 bytes writes U+FFFD back and the old tail survives past
the new text. That is how String (UTF-8) has always behaved — reads
decode with errors="replace" and prepare_write never pads — so it is a
property of writing text over binary, not of making regex writable.
Changing it means changing what a string write means.
@JeanExtreme002
JeanExtreme002 merged commit 7c7f80c into mainAug 28, 2026
13 checks passed
@github-actions
github-actionsBot deleted the fix/byte-array-infers-length branch August 28, 2026 20:50
@JeanExtreme002JeanExtreme002 mentioned this pull request Aug 28, 2026
2 tasks
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

appGUI application changes (PyMemoryEditor/app/)libLibrary changes (PyMemoryEditor/)testsTest changes (tests/)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Byte array search shouldn't need to specify length

1 participant

@JeanExtreme002
, '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

fix(app): infer the byte-array scan width from the value entered - #87

Merged
JeanExtreme002 merged 19 commits into
mainfrom
fix/byte-array-infers-length
Aug 28, 2026
Merged

fix(app): infer the byte-array scan width from the value entered#87
JeanExtreme002 merged 19 commits into
mainfrom
fix/byte-array-infers-length

Conversation

@JeanExtreme002

@JeanExtreme002JeanExtreme002 commented Aug 27, 2026

Copy link
Copy Markdown
Owner

Why is this PR necessary, what does it do?

Fixes#79, and the run of related width bugs that surfaced while doing it.

The reported bug

Scanning for a Byte Array (Hex) required the user to keep the Length field
in sync by hand with the bytes they typed, and got both failure modes wrong:

  • a value wider than Length was squeezed into a fixed-width ctypes buffer
    and the scan died with ValueError: byte string too long. The default Length
    is 4, so any 5-byte value hit this — exactly what Byte array search shouldn't need to specify length #79 reports;
  • a value narrower than Length was NUL-padded, silently turning the scan
    into "these bytes followed by zeros": no error, just a scan that finds
    nothing and never says why.

String (UTF-8) already derived its width from the typed text, and the library
itself infers it in resolve_bufflength_for_value — the scanner panel was the
only place still asking the user to count. Byte Array now follows the same rule,
and the Length field became a read-only readout of what the value sizes to.
Partial matching keeps its own value type (AOB Pattern (IDA)).

One width, one source

Fixing that exposed the same mistake in every other place that needed "the width
of the rows currently on screen" and read the Length field instead — which
follows the Value box, and the user can retype that between scans:

  • promote to cheat table created entries at the wrong width (a 4-byte olá
    scan promoted at the spec's 16, so the cell pulled 12 bytes of neighbouring
    memory in on every poll tick);
  • the no-value comparisons (Changed / Unchanged / Increased / Decreased)
    re-read at a width the baseline was never recorded at, so every address
    reported as changed;
  • Update Values re-read at whatever was in the Value box, overwriting every
    stored value with a truncated read — and aborted outright with
    "Invalid value" if that box happened to be empty;
  • the readout ignored a range's upper bound while the scan sized with
    max(lo, hi).

They all take it from _last_scan_length now — the width of the scan that
produced the rows — which is adopted when a scan lands, not when it is
dispatched, so one that errors, is cancelled, or is rejected by its handler
can't leave a width behind for a later refresh to pick up. is_sized_by_value()
replaced the "String or Byte Array, but not a pattern" test that was being
spelled out by hand at each site, which is what kept producing these.

Pattern types now round-trip as cheat entries

Giving AOB entries a real width made their cheat-table cell editable for the
first time, and it was wired backwards: the cell formats what it reads as hex
but parsed an edit as a pattern, so typing 00 wrote ASCII 0x30 0x30 into
the target and reported success.

The cause was the spec answering one question. spec.parse answers the
scanner's — "what am I searching for?" — and for an IDA pattern that is the
pattern text, which is not a value any address can hold. A cheat entry is
read and written every poll tick, so its type has to round-trip:
format(bytes read) must parse back to the same bytes.

The specs answer both questions now. ValueTypeSpec.parse_write turns cell text
into the bytes to write and receives the value last read at the address;
parse_value_for_write picks it when a spec has one. The scanner keeps asking
parse_value — it only ever searches — and the cheat table only ever writes, so
it no longer imports parse_value at all.

For an IDA pattern the tokens resolve to the bytes they name, and ? keeps the
byte already at that offset:

cell shows 48 8B 12 34 00
edit "48 8B 90 90 00" → writes 48 8B 90 90 00
edit "48 8B ? ? 90" → writes 48 8B 12 34 90 (the ?s are preserved)

That mirrors the wildcard's search meaning and is what makes a signature
patchable — you name the bytes you mean to change and leave the operands alone,
the same semantics Cheat Engine uses. A wildcard with nothing read yet is
refused with a message rather than writing a zero. A regex names a set of byte
strings rather than one, so there the cell text is taken literally, matching what
the cell displays. tokenize_pattern comes out of compile_pattern so the
search and write paths share one set of token rules and error messages.

Writing a value must not resize the entry that holds it, either. parse_value
honours an explicit length override, so before this no value edit ever changed
an entry's width; the new write path returned len(value) unconditionally, and
Regex does accept an override, so bulk-editing a 64-byte regex entry to "hi"
collapsed it to 2 bytes and the row displayed 2 bytes of the address from then
on. The write path follows the same override rule as the search path now.

Neighbouring, same cause: every path that fell back to the AOB spec's declared
length of 0 — promotion, adding an address by hand, changing an entry's type,
importing a JSON table — minted a zero-width entry that read back empty on every
tick. add_entry floors the width, since it is the one door all of those pass
through.

The pointer dialogs keep the regex type

Both dialogs read a value at an address the user already has, and both filtered
their "Read value as" list by is_pattern — which lumps together two specs that
behave nothing alike once the address is known.

An IDA pattern genuinely can't be offered there: it declares a length of 0 (the
scanner derives a match's width from the pattern) and refuses a length
override, so the dialog read zero bytes and displayed nothing. A regex has
neither problem — its width comes from the Length field, and it renders bytes as
text up to the first NUL, which String (UTF-8) does not:

memory: b"Player42\0\xff\xfe\x01rest"
Regex (String) → "Player42"
String (UTF-8) → "Player42\0\ufffd\ufffd\x01rest"

So it isn't a duplicate of anything else in the list. has_readable_width()
replaces the is_pattern test and asks what actually matters — can this spec
size a read at an address I already have? — derived from the spec rather than
from a label. Pointer Scan had the old filter before this branch and Pointer
Chain gained it during it; both were dropping the regex for the same wrong
reason, so both are corrected. The cheat table keeps offering both types, since
an entry carries its own width.

Comparisons that never worked

Increased/Decreased Value By on String / Byte Array: the comparator does
prev + exp, which concatenates rather than adds and is never equal to the
fixed-width value read back, while prev - exp raises TypeError that the
refine worker swallows into "doesn't match". Every address was dropped in
silence. The combination is rejected with a message pointing at Changed /
Unchanged Value. An empty String value likewise sized to a 1-byte NUL
buffer, so scanning for it matched every zeroed byte in the target; Byte Array
already rejected its own empty input.

Checklist (complete all items):

  • Added tests as necessary.
  • There is no breaking change for existing features.

References:

Closes#79

Notes:

Reproduction of the reported bug, and the same call after the fix:

build_scan_request(BYTES, EXACT_VALUE, value_text='00 11 22 AA BB CC', length_spin_value=4)
# before → length=4 → value_to_bytes(...) → ValueError: byte string too long# after → length=6 → value_to_bytes(...) → b'\x00\x11"\xaa\xbb\xcc'

Deliberately left alone: for VALUE_BETWEEN with endpoints of different widths
the shorter one is still NUL-padded up to the wider. That is documented library
behaviour (resolve_bufflength_for_value), it is what makes a fixed-width
comparison possible when the user gives two different widths, and str has
always behaved the same way.

Suite goes from 486 to 520 passing with no new failures; make lint is clean and
make type-check reports the same pre-existing errors as main (none in the
files touched). The 15 still-failing tests are unrelated to this branch: they are the macOS
KERN_MEMORY_ERROR scan abort fixed by #88, and they fail the same way on
main. With both branches merged locally the whole suite passes — 538 tests,
no failures — which is the first time the real memory read/write path has been
exercised here at all.

Scanning for a Byte Array (Hex) required the user to keep the Length
field in sync with the bytes they typed, and got both failure modes
wrong when they didn't:
* a value wider than Length was squeezed into a fixed-width ctypes
buffer and the scan died with "ValueError: byte string too long"
(the default Length is 4, so any 5-byte value hit this);
* a value narrower than Length was NUL-padded, silently turning the
scan into "these bytes followed by zeros" with no feedback.
String (UTF-8) already derived its width from the typed text, and the
library itself infers it in resolve_bufflength_for_value — the scanner
panel was the only place still asking. Byte Array now follows the same
rule: the Length field becomes a read-only readout of the parsed byte
count, and build_scan_request lets parse_value size the buffer. Partial
matching keeps its own value type (AOB Pattern).
This also fixes promoting byte-array results to the cheat table, which
read the same spin box and so created entries truncated to 4 bytes.
The no-value comparisons (Increased / Changed / …) still read the
Length field: they carry no value to measure, and the readout holds the
width the previous scan settled on.
Closes#79
@github-actionsgithub-actionsBot added app GUI application changes (PyMemoryEditor/app/) lib Library changes (PyMemoryEditor/) tests Test changes (tests/) labels Aug 27, 2026
Two problems in the same neighbourhood, surfaced by review of the
byte-array fix.
The no-value comparisons (Increased / Changed / …) carry no value to
measure, so they read the Length field — but str was excluded and fell
back to the spec default of 16. Picking "Changed Value" after a 4-byte
"olá" scan refined it by re-reading 16 bytes per address, so the value
read back ("olá\0\0…") never equalled the "olá" the first scan had
recorded and every address reported as Changed. The readout now holds
its last valid width when the value field is cleared (it already did
for byte arrays), and the no-value branch honours it for both types.
Promoting such a row to the cheat table was hitting the same 1-byte
readout and is fixed with it.
Switching value type left the readout showing a width computed for the
previous type when the value doesn't parse as the new one — 21 bytes of
string carried over into Byte Array, for instance. That field is
read-only now, so the user had no way to correct it. Selecting a
value-sized type seeds the spec default before syncing.
Follow-up to #79.
Opening the app lands on Int32, whose Length reads 4. Picking Byte
Array (Hex) kept showing that 4 — it was never a width the byte-array
scan would use — and the first hex digit typed dropped it to 1, so the
one number the field ever showed on its own was wrong.
String and Byte Array take their width from the value, and the field is
read-only for them, so there is nothing to show and no way for the user
to correct whatever we invent. The spin now opens a 0 slot rendered as
"— (set by the value)" for that state, and fills in a real number as
soon as the value sizes to one. The two consumers that could see the 0
(the no-value next-scan width and the promote-to-cheat-table length)
fall back to the spec default.
Selecting any other type clears the special-value text and restores the
minimum of 1, so a 1-byte Int8 shows "1 bytes" rather than landing on
the empty-slot rendering.
Follow-up to #79.
…ded with
Increased / Decreased / Changed / Unchanged compare against the values
the previous scan recorded, so they have to re-read at that scan's
width. They were reading the Length field instead, which tracks the
value currently in the Value box — and the user can edit that between
scans without rescanning. First Scan for the byte array `00 11`, type
`00 11 22 33`, pick "Changed Value", Next Scan: every address is
re-read 4 bytes wide, compared against a 2-byte baseline, and reported
as Changed. "Update Values" was worse, writing the mismatched reads
back over the baseline for every later comparison.
The panel now records the width of each scan it emits and passes it as
previous_scan_length; it's dropped along with the results it describes.
The Length readout no longer has to double as that memory, so it goes
back to being exactly what the current value sizes to — which also
fixes it claiming "4 bytes" for a String whose value box had been
cleared while the request was built at length 1.
Two neighbouring bugs on the same paths:
* promoting an AOB hit to the cheat table created a zero-width entry
(no Length field, and the IDA spec's length is 0), re-read as empty on
every poll tick. It now measures the pattern — one token, one byte;
* an empty String value parsed to a 1-byte NUL buffer, so scanning it
matched every zeroed byte in the target. Byte Array already rejected
its own empty input; both now do.
Follow-up to #79.
…ade them
Third round of the same mistake, in the paths the previous fix didn't
reach. All four take the width from the Length readout, which follows
the Value box — a no-value scan type clears that box outright, and the
user can retype it between scans without rescanning:
* promoting to the cheat table fell through to the spec default: a
4-byte "olá" scan promoted at 16, so the entry pulled 12 bytes of
neighbouring memory into the cell on every poll tick;
* "Increased/Decreased value BY" sit outside NO_VALUE_SCAN_TYPES, so
they missed the previous fix and sized the re-read from the *delta*
text — a 4-byte baseline re-read 1 byte at a time, dropping every
address, and then written back as the new baseline width;
* "Update Values" is a read-only refresh, but re-read at whatever was
in the Value box and moved the baseline to match, overwriting every
stored value with a truncated read;
* the readout ignored the range upper bound while the scan sized with
max(lo, hi), so a "AA".."AA BB CC" range reported 1 byte and ran at 3.
All four now use the width the results were scanned at. _sync_value_length
reads both bounds and no longer needs its callers to check the type.
Also: re-picking "AOB Pattern (IDA)" on a promoted hit reset the entry
to a zero-width buffer (the spec's own length is 0), undoing the pattern
width from the other side; and the empty-value message is neutral now
that it surfaces in the cheat table's write dialogs too.
Follow-up to #79.
…ng it
Fourth round on the same class, so this one names the concept instead of
patching another site: is_sized_by_value(spec) is now a single predicate
the panel uses everywhere it previously spelled out "String or Byte
Array, but not a pattern" by hand — which is what kept producing these.
That gate was wrong in one place already: accepts_length_override is
True for Regex too, so the width override caught it and made its Length
field — the only editable one, and the user's own byte_length — inert
after a first scan. Update Values and promote honour it again.
Update Values was building a full request just to throw the value away
(RefineScanWorker applies no comparison when filter_only is False). With
the new empty-value rejection that turned into a hard regression: a
Value box cleared by a no-value scan type aborted the refresh with
"Invalid value" instead of refreshing. It builds the request directly
now and can't fail.
And the baseline width is adopted when a scan *lands*, not when it is
dispatched: set_has_results is called by the owner on completion, so a
Next Scan whose worker errors out no longer leaves a width the values on
screen were never read at — which the following Changed/Unchanged refine
would have compared against.
Also: bulk edit collapsed AOB entries to a zero-width buffer exactly as
_change_type did before the last commit, and manually adding an AOB
address created one from the start; both ask for or keep a real width.
Follow-up to #79.
Chasing the zero-width AOB entry one call site at a time found two more:
importing a cheat table whose rows were saved by a build that promoted
AOB hits at the spec's 0 (`raw.get("length") or spec.length` is `0 or 0`),
and the pointer-chain dialog, which — unlike the pointer-scan dialog
right next to it — offered the pattern types in its "Read value as"
combo, so `_current_spec()` handed back the spec's 0.
Rather than a fourth guard, the floor now sits in add_entry: every entry
enters the table through it, whichever way it was made — promoted from a
scan, from either pointer dialog, added by hand, or loaded from JSON.
A zero-width entry can't be spotted from the table (the Value column
just reads back empty forever), so this is worth enforcing once at the
boundary rather than trusting each producer.
The pointer-chain dialog also gets the filter its sibling already had,
with the same reasoning: reading a value "as a pattern" is meaningless
when the address is already known.
Follow-up to #79.
…r worked
Last round's baseline fix promoted a pending width on any report of
results, and I reasoned that only a completed scan could produce one.
That was wrong: "Update Values" finishes through the same handler. So a
Next Scan whose worker errored left its width behind, and the next plain
refresh adopted it — 2-byte values on screen, baseline claiming 4, and
the following Changed Value refine drops everything. The pending width is
now discarded when the scan cycle ends (set_busy(False), which the owner
emits after the completion signal), so only the scan that actually landed
can have set it.
"Increased/Decreased Value By" never worked on String / Byte Array: the
comparator does `prev + exp`, which concatenates rather than adds and is
never equal to the fixed-width value read back, while `prev - exp` raises
TypeError that the refine worker swallows into "doesn't match". Every
address was dropped and nothing said why. The combination is rejected
with a message pointing at Changed / Unchanged Value, and last round's
width branch for it goes away — it was sizing a comparison that could
never be true.
Also: the cheat table's "Change buffer length" dialog capped at 1024
while a promoted String / Byte Array entry can be as wide as the value
scanned for, so opening it and pressing OK silently shrank the entry.
Follow-up to #79.
… edges
Giving AOB entries a real width made their Value cell live for the first
time, and it was wired backwards: the cell formats the bytes it reads as
hex, but parses an edit as a *pattern*, so typing "00" wrote ASCII
0x30 0x30 rather than the byte 0x00 — reported as a successful write. A
pattern finds an address; it can't hold a value. Hits promote as Byte
Array at the pattern's width now, so the cell reads and writes the same
thing. The zero-width buffer used to make every such write fail, which
is why this never showed.
Two more edges on the pending-width adoption:
* a cancelled refine still reports results (the worker breaks out of its
loop and emits finished_ok with what it kept), but only the rows it
reached were re-read at the new width. Neither width describes the
table, so cancelling keeps the one most rows were read at;
* a request the owner rejects before the busy cycle begins (its handler
returns early) never reaches set_busy(False), so its width lingered.
"Update Values" now records the width it re-reads at — which is what
the table will hold once it lands, and overwrites anything stale.
Also: the Length field's enable expression still tested a clause that
can no longer be true, reading as if more types than Regex could make it
editable. Regex is the only spec whose width is genuinely the user's.
Follow-up to #79.
Self-review of the whole diff, on the final state rather than commit by
commit. Three things it turned up.
The write-ASCII bug wasn't fixed, only patched at one of its three
doors. Substituting Byte Array inside current_spec_and_length covered
promotion, but adding an address by hand with the AOB type, changing an
existing entry's type to AOB, and importing a JSON table written before
that change all still produced a pattern-typed entry — whose cell
displays hex and writes the ASCII spelling of what you type. A pattern
is search syntax: a way to find an address, never a shape a value is
read and written back as. The type pickers now offer HOLDABLE_TYPE_LABELS
(the non-pattern specs, the same filter the pointer dialogs already use)
and add_entry re-types anything that still arrives — it is the one door
promote, manual add and JSON import all pass through. The substitution
comes out of current_spec_and_length, which was also handing the wrong
spec to "Update Values"; three now-unreachable `or`-guards for the AOB
zero come out with it, since they implied a zero could still arrive.
The delta rejection sat before the pattern short-circuit. Both pattern
specs are bytes-typed, so an AOB scan with a delta type raised instead
of forcing EXACT as the pattern path documents — and pointed the user at
Changed/Unchanged Value, which are disabled in pattern mode. Moved
below, where it means what it says.
Also: "Update Values" carried two comments contradicting each other
about whether it moves the baseline (it does, to the width it re-reads
at), and the Length field's enable rule had two overlapping rationales
left over from when the expression was more than `is_regex`.
Follow-up to #79.
Reverts removing AOB / Regex from the cheat table in favour of fixing
what was actually broken about them.
A cheat entry is read and written every poll tick, so its type has to
round-trip: format(bytes read) must parse back to the same bytes. The
pattern specs failed that because `parse` answers a different question —
"what am I searching for?" — and for an IDA pattern it hands back the
pattern *text*, so a cell displaying `48 8B 00` wrote the ASCII spelling
of that hex.
The specs now answer both questions. `ValueTypeSpec.parse_write` turns
cell text into the bytes to write, and takes the value last read at the
address; `parse_value_for_write` picks it when a spec has one. The
scanner keeps asking `parse_value` — it only ever searches — and the
cheat table only ever writes, so it no longer imports `parse_value` at
all.
For an IDA pattern that means the tokens resolve to the bytes they name,
and `?` keeps the byte already at that offset: `48 8B ? ? 90` over
`48 8B 12 34 00` writes `48 8B 12 34 90`. That mirrors the wildcard's
search meaning and is what makes a signature patchable — you name the
bytes you mean to change and leave the operands alone. A wildcard with
nothing read yet is refused with a message rather than writing a zero.
For a regex, which names a set of byte strings rather than one, the cell
text is taken literally, matching what the cell displays.
`tokenize_pattern` comes out of `compile_pattern` so the search and write
paths share one set of token rules and error messages.
Follow-up to #79.
Self-review of the parse_write commit. The cheat table's bulk edit
stores the width parse_value_for_write returns back onto the entry, and
that width is how many bytes the row *reads* on every poll tick — set by
the user, and none of a write's business.
parse_value honours an explicit length_override for the types that
accept one, so before parse_write existed no value edit ever resized an
entry. The new path returned len(value) unconditionally, and Regex does
accept an override: bulk-editing a 64-byte regex entry to "hi" collapsed
it to 2 bytes, so the row then displayed 2 bytes of the address forever.
It now follows the same override rule as parse_value.
An IDA pattern is unaffected either way — it declines a length override,
so the cheat table never writes the returned width onto the entry — but
it now reports the pattern's own byte count rather than the search
path's 0, which is not a size any buffer could have.
Both pointer dialogs filtered their "Read value as" list by is_pattern,
which lumps together two specs that behave nothing alike once the
address is already known.
An IDA pattern genuinely can't be offered: it declares a length of 0 —
the scanner derives a match's width from the pattern — and refuses a
length override, so the dialog read zero bytes and displayed nothing. A
regex has neither problem: its width comes from the Length field, and it
renders the bytes as text up to the first NUL, which "String (UTF-8)"
does not. Reading b"Player42\0\xff\xfe\x01rest" shows "Player42" as a
regex and "Player42\0��\x01rest" as a string, so it isn't a
duplicate of anything else in the list.
has_readable_width() replaces the is_pattern test and asks the question
that actually matters — can this spec size a read at an address I
already have? — derived from the spec rather than from a label. The
cheat table keeps offering both, since an entry carries its own width.
Pointer Scan had this filter before the branch and Pointer Chain gained
it earlier in it; both were dropping the regex for the same wrong
reason, so both are corrected.
An entry's last_value / frozen_value hold whatever the *previous* spec
decoded, and nothing waits for a fresh poll tick after the type changes,
so the new spec was being applied to the old type's value. Two ways out:
* _rebuild formats it — _fmt_bytes(1234) raises TypeError from inside a
Qt slot, so an Int32 row retyped to Byte Array or AOB left the table
un-rebuilt; a frozen entry would also republish the old value to the
poll worker under the new pytype;
* the bulk edit's "set type" and "set value" run in the same pass, so an
IDA pattern's wildcard reached len() on an int. That raised TypeError
too, which neither call site's `except ValueError` catches.
Both branches now forget the cached value when the label actually
changes — the next tick refills it — and the pattern write path treats a
non-bytes `current` as "nothing read yet", so it stays inside the
ValueError contract its callers handle.
Cancelling a *first* scan no longer discards its width. Both workers
report partial results after a cancel, but only a refine leaves the rows
at mixed widths; every address a first scan found, it found at its own
width, and there is no earlier one to fall back on — so the next
Changed/Unchanged refine went to the spec default, 16 for a String
scanned at 4, which is the failure this branch exists to fix.
_has_results tells the two apart.
Also: an IDA pattern wider than the entry it is typed into was truncated
by prepare_write without a word, while the same edit one byte short was
refused by the wildcard check; it is refused now too. And
current_spec_and_length still documented a re-typing that was reverted
several commits ago.
tokenize_pattern comes back out of util.pattern.__all__ — it exists so
the scan and write paths share token rules, nothing outside the package
consumes it, and neither the guide nor the API reference describes it.
Follow-ups from review of the previous commit, three of them consequences
of it.
Dropping frozen_value on a type change left `frozen` ticked with no
baseline, and nothing re-adopted one: the poll worker skips a frozen
entry whose frozen_value is None, so the Active box stayed on while the
freeze silently never wrote again until the user toggled it. The next
tick now adopts the first value read under the new type.
A value wider than its entry was written short without a word — the cell
kept showing all of it until the next tick corrected itself — because
prepare_write treats the entry width as a hard truncating cap. The
previous commit added exactly this guard for patterns and left String
and Byte Array silently dropping the tail; all three refuse it now.
An IDA pattern's promotion width was re-derived from the Value box,
which stays editable in pattern mode, so editing the pattern after a
scan promoted (or refreshed) at a width the hits were never found at —
the same staleness _last_scan_length exists to prevent. The pattern path
couldn't use it because a pattern request reports a length of 0, which
never promoted; the dispatched width is now the token count.
Also: the wildcard's out-of-range message read as though the entry were
permanently too narrow, when what it measures is the last read, which a
narrower write shortens until the next tick.
…ress
Three misses from the same habit — fixing a case instead of the rule.
add_entry's "address already exists" branch is the third place a
spec_label changes, and the previous commit fixed the other two. Promote
an address already in the table under a different type — from a scan or
from either pointer dialog — and the value the old spec decoded survived
into _rebuild, where the new spec's formatter met it:
_fmt_bytes("hello") raises ValueError from inside add_entry itself.
The width guard was in two places and wrong in both. On the parse_write
path it existed only for IDA patterns, so a regex entry got exactly the
silent truncation the guard was added to stop. And it counted len(value)
— characters for str — against a width that is a byte count, so "ábc"
(3 characters, 4 bytes) passed the check for a 3-byte entry and
prepare_write put a byte through the far wall. There is now one guard,
after the value exists, measuring what actually goes on the wire; it
covers String, Byte Array, Regex and IDA patterns alike.
Not changed: current_spec_and_length still reads the live Length field
for a regex rather than _last_scan_length. That field is the user's
byte_length and the only editable width in the app; an earlier review
flagged the opposite — that preferring the scan width left it inert —
and Next Scan is disabled in pattern mode, so no baseline comparison can
be thrown off by it. Editing it and refreshing is the control working.
Three from review of the previous commit.
Changing a row's type and writing a value in the same bulk edit failed
every selected row: the type change forgets the cached value, and the
write in the same iteration then had nothing for an IDA '?' to keep —
"can't be used before the value has been read at least once", about
bytes that were sitting right there. Byte Array → AOB doesn't change
what those bytes mean, and doing the two steps separately worked, which
is the tell. The write now captures the value before the forget runs.
Forgetting stays unconditional: a switch that keeps the pytype can still
change the width (Int32 → Int16), and parse_value_for_write already
ignores a `current` the new type could not have produced.
"Change buffer length" capped at max(1024, entry.length), so an entry
already wider than 1024 — which is normal now that entries are promoted
at the width of the value scanned for — could only ever be shrunk, while
a wider value couldn't be written into it either, since the width guard
refuses that. No way out from inside the app. It uses the same ceiling
the scanner does.
And the Type column showed the width only for specs that accept a length
override, which excludes the IDA pattern — the one spec whose width this
branch made real and enforced. Being told to widen an entry whose width
is nowhere on screen isn't much of an instruction.
Three dead ends the previous commit's guards created, all found by
review of it.
A bulk edit that retypes rows to a wider type refused every one of them.
Three Int32 rows retyped to String with "hello" hit "the entry holds 4 —
widen the entry first", and there is no width field in the bulk dialog,
nor a "change length" on a multi-row selection: no way out from inside
the app. A retype to a variable-width spec now takes the width from the
value it writes, which is what promotion does too.
The width ceiling went from a too-tight 1024 to no bound at all, and the
poll worker allocates the entry's width for every row on every 100 ms
tick — one typo away from a 2 GB allocation the tick's blanket except
swallows and retries forever. MAX_ENTRY_LENGTH bounds it at a megabyte,
far past any value a scan produces, and the manual-add dialog uses the
same number instead of disagreeing at 1024.
Re-arming a frozen row on the next tick after a type change traded a
no-op for a wrong write: with frozen_value gone and the box still
ticked, the tick adopted whatever the address happened to hold and the
app started pinning a value the user never chose. The type change
releases the freeze now. The re-arm stays for the case it was written
for — a box ticked before the first read, which is a deliberate action
on an unchanged type.
Not changed: a regex cell writes its text literally, so editing one that
held non-UTF-8 bytes writes U+FFFD back and the old tail survives past
the new text. That is how String (UTF-8) has always behaved — reads
decode with errors="replace" and prepare_write never pads — so it is a
property of writing text over binary, not of making regex writable.
Changing it means changing what a string write means.
@JeanExtreme002
JeanExtreme002 merged commit 7c7f80c into mainAug 28, 2026
13 checks passed
@github-actions
github-actionsBot deleted the fix/byte-array-infers-length branch August 28, 2026 20:50
@JeanExtreme002JeanExtreme002 mentioned this pull request Aug 28, 2026
2 tasks
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

appGUI application changes (PyMemoryEditor/app/)libLibrary changes (PyMemoryEditor/)testsTest changes (tests/)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Byte array search shouldn't need to specify length

1 participant

@JeanExtreme002
, '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

fix(app): infer the byte-array scan width from the value entered - #87

Merged
JeanExtreme002 merged 19 commits into
mainfrom
fix/byte-array-infers-length
Aug 28, 2026
Merged

fix(app): infer the byte-array scan width from the value entered#87
JeanExtreme002 merged 19 commits into
mainfrom
fix/byte-array-infers-length

Conversation

@JeanExtreme002

@JeanExtreme002JeanExtreme002 commented Aug 27, 2026

Copy link
Copy Markdown
Owner

Why is this PR necessary, what does it do?

Fixes#79, and the run of related width bugs that surfaced while doing it.

The reported bug

Scanning for a Byte Array (Hex) required the user to keep the Length field
in sync by hand with the bytes they typed, and got both failure modes wrong:

  • a value wider than Length was squeezed into a fixed-width ctypes buffer
    and the scan died with ValueError: byte string too long. The default Length
    is 4, so any 5-byte value hit this — exactly what Byte array search shouldn't need to specify length #79 reports;
  • a value narrower than Length was NUL-padded, silently turning the scan
    into "these bytes followed by zeros": no error, just a scan that finds
    nothing and never says why.

String (UTF-8) already derived its width from the typed text, and the library
itself infers it in resolve_bufflength_for_value — the scanner panel was the
only place still asking the user to count. Byte Array now follows the same rule,
and the Length field became a read-only readout of what the value sizes to.
Partial matching keeps its own value type (AOB Pattern (IDA)).

One width, one source

Fixing that exposed the same mistake in every other place that needed "the width
of the rows currently on screen" and read the Length field instead — which
follows the Value box, and the user can retype that between scans:

  • promote to cheat table created entries at the wrong width (a 4-byte olá
    scan promoted at the spec's 16, so the cell pulled 12 bytes of neighbouring
    memory in on every poll tick);
  • the no-value comparisons (Changed / Unchanged / Increased / Decreased)
    re-read at a width the baseline was never recorded at, so every address
    reported as changed;
  • Update Values re-read at whatever was in the Value box, overwriting every
    stored value with a truncated read — and aborted outright with
    "Invalid value" if that box happened to be empty;
  • the readout ignored a range's upper bound while the scan sized with
    max(lo, hi).

They all take it from _last_scan_length now — the width of the scan that
produced the rows — which is adopted when a scan lands, not when it is
dispatched, so one that errors, is cancelled, or is rejected by its handler
can't leave a width behind for a later refresh to pick up. is_sized_by_value()
replaced the "String or Byte Array, but not a pattern" test that was being
spelled out by hand at each site, which is what kept producing these.

Pattern types now round-trip as cheat entries

Giving AOB entries a real width made their cheat-table cell editable for the
first time, and it was wired backwards: the cell formats what it reads as hex
but parsed an edit as a pattern, so typing 00 wrote ASCII 0x30 0x30 into
the target and reported success.

The cause was the spec answering one question. spec.parse answers the
scanner's — "what am I searching for?" — and for an IDA pattern that is the
pattern text, which is not a value any address can hold. A cheat entry is
read and written every poll tick, so its type has to round-trip:
format(bytes read) must parse back to the same bytes.

The specs answer both questions now. ValueTypeSpec.parse_write turns cell text
into the bytes to write and receives the value last read at the address;
parse_value_for_write picks it when a spec has one. The scanner keeps asking
parse_value — it only ever searches — and the cheat table only ever writes, so
it no longer imports parse_value at all.

For an IDA pattern the tokens resolve to the bytes they name, and ? keeps the
byte already at that offset:

cell shows 48 8B 12 34 00
edit "48 8B 90 90 00" → writes 48 8B 90 90 00
edit "48 8B ? ? 90" → writes 48 8B 12 34 90 (the ?s are preserved)

That mirrors the wildcard's search meaning and is what makes a signature
patchable — you name the bytes you mean to change and leave the operands alone,
the same semantics Cheat Engine uses. A wildcard with nothing read yet is
refused with a message rather than writing a zero. A regex names a set of byte
strings rather than one, so there the cell text is taken literally, matching what
the cell displays. tokenize_pattern comes out of compile_pattern so the
search and write paths share one set of token rules and error messages.

Writing a value must not resize the entry that holds it, either. parse_value
honours an explicit length override, so before this no value edit ever changed
an entry's width; the new write path returned len(value) unconditionally, and
Regex does accept an override, so bulk-editing a 64-byte regex entry to "hi"
collapsed it to 2 bytes and the row displayed 2 bytes of the address from then
on. The write path follows the same override rule as the search path now.

Neighbouring, same cause: every path that fell back to the AOB spec's declared
length of 0 — promotion, adding an address by hand, changing an entry's type,
importing a JSON table — minted a zero-width entry that read back empty on every
tick. add_entry floors the width, since it is the one door all of those pass
through.

The pointer dialogs keep the regex type

Both dialogs read a value at an address the user already has, and both filtered
their "Read value as" list by is_pattern — which lumps together two specs that
behave nothing alike once the address is known.

An IDA pattern genuinely can't be offered there: it declares a length of 0 (the
scanner derives a match's width from the pattern) and refuses a length
override, so the dialog read zero bytes and displayed nothing. A regex has
neither problem — its width comes from the Length field, and it renders bytes as
text up to the first NUL, which String (UTF-8) does not:

memory: b"Player42\0\xff\xfe\x01rest"
Regex (String) → "Player42"
String (UTF-8) → "Player42\0\ufffd\ufffd\x01rest"

So it isn't a duplicate of anything else in the list. has_readable_width()
replaces the is_pattern test and asks what actually matters — can this spec
size a read at an address I already have? — derived from the spec rather than
from a label. Pointer Scan had the old filter before this branch and Pointer
Chain gained it during it; both were dropping the regex for the same wrong
reason, so both are corrected. The cheat table keeps offering both types, since
an entry carries its own width.

Comparisons that never worked

Increased/Decreased Value By on String / Byte Array: the comparator does
prev + exp, which concatenates rather than adds and is never equal to the
fixed-width value read back, while prev - exp raises TypeError that the
refine worker swallows into "doesn't match". Every address was dropped in
silence. The combination is rejected with a message pointing at Changed /
Unchanged Value. An empty String value likewise sized to a 1-byte NUL
buffer, so scanning for it matched every zeroed byte in the target; Byte Array
already rejected its own empty input.

Checklist (complete all items):

  • Added tests as necessary.
  • There is no breaking change for existing features.

References:

Closes#79

Notes:

Reproduction of the reported bug, and the same call after the fix:

build_scan_request(BYTES, EXACT_VALUE, value_text='00 11 22 AA BB CC', length_spin_value=4)
# before → length=4 → value_to_bytes(...) → ValueError: byte string too long# after → length=6 → value_to_bytes(...) → b'\x00\x11"\xaa\xbb\xcc'

Deliberately left alone: for VALUE_BETWEEN with endpoints of different widths
the shorter one is still NUL-padded up to the wider. That is documented library
behaviour (resolve_bufflength_for_value), it is what makes a fixed-width
comparison possible when the user gives two different widths, and str has
always behaved the same way.

Suite goes from 486 to 520 passing with no new failures; make lint is clean and
make type-check reports the same pre-existing errors as main (none in the
files touched). The 15 still-failing tests are unrelated to this branch: they are the macOS
KERN_MEMORY_ERROR scan abort fixed by #88, and they fail the same way on
main. With both branches merged locally the whole suite passes — 538 tests,
no failures — which is the first time the real memory read/write path has been
exercised here at all.

Scanning for a Byte Array (Hex) required the user to keep the Length
field in sync with the bytes they typed, and got both failure modes
wrong when they didn't:
* a value wider than Length was squeezed into a fixed-width ctypes
buffer and the scan died with "ValueError: byte string too long"
(the default Length is 4, so any 5-byte value hit this);
* a value narrower than Length was NUL-padded, silently turning the
scan into "these bytes followed by zeros" with no feedback.
String (UTF-8) already derived its width from the typed text, and the
library itself infers it in resolve_bufflength_for_value — the scanner
panel was the only place still asking. Byte Array now follows the same
rule: the Length field becomes a read-only readout of the parsed byte
count, and build_scan_request lets parse_value size the buffer. Partial
matching keeps its own value type (AOB Pattern).
This also fixes promoting byte-array results to the cheat table, which
read the same spin box and so created entries truncated to 4 bytes.
The no-value comparisons (Increased / Changed / …) still read the
Length field: they carry no value to measure, and the readout holds the
width the previous scan settled on.
Closes#79
@github-actionsgithub-actionsBot added app GUI application changes (PyMemoryEditor/app/) lib Library changes (PyMemoryEditor/) tests Test changes (tests/) labels Aug 27, 2026
Two problems in the same neighbourhood, surfaced by review of the
byte-array fix.
The no-value comparisons (Increased / Changed / …) carry no value to
measure, so they read the Length field — but str was excluded and fell
back to the spec default of 16. Picking "Changed Value" after a 4-byte
"olá" scan refined it by re-reading 16 bytes per address, so the value
read back ("olá\0\0…") never equalled the "olá" the first scan had
recorded and every address reported as Changed. The readout now holds
its last valid width when the value field is cleared (it already did
for byte arrays), and the no-value branch honours it for both types.
Promoting such a row to the cheat table was hitting the same 1-byte
readout and is fixed with it.
Switching value type left the readout showing a width computed for the
previous type when the value doesn't parse as the new one — 21 bytes of
string carried over into Byte Array, for instance. That field is
read-only now, so the user had no way to correct it. Selecting a
value-sized type seeds the spec default before syncing.
Follow-up to #79.
Opening the app lands on Int32, whose Length reads 4. Picking Byte
Array (Hex) kept showing that 4 — it was never a width the byte-array
scan would use — and the first hex digit typed dropped it to 1, so the
one number the field ever showed on its own was wrong.
String and Byte Array take their width from the value, and the field is
read-only for them, so there is nothing to show and no way for the user
to correct whatever we invent. The spin now opens a 0 slot rendered as
"— (set by the value)" for that state, and fills in a real number as
soon as the value sizes to one. The two consumers that could see the 0
(the no-value next-scan width and the promote-to-cheat-table length)
fall back to the spec default.
Selecting any other type clears the special-value text and restores the
minimum of 1, so a 1-byte Int8 shows "1 bytes" rather than landing on
the empty-slot rendering.
Follow-up to #79.
…ded with
Increased / Decreased / Changed / Unchanged compare against the values
the previous scan recorded, so they have to re-read at that scan's
width. They were reading the Length field instead, which tracks the
value currently in the Value box — and the user can edit that between
scans without rescanning. First Scan for the byte array `00 11`, type
`00 11 22 33`, pick "Changed Value", Next Scan: every address is
re-read 4 bytes wide, compared against a 2-byte baseline, and reported
as Changed. "Update Values" was worse, writing the mismatched reads
back over the baseline for every later comparison.
The panel now records the width of each scan it emits and passes it as
previous_scan_length; it's dropped along with the results it describes.
The Length readout no longer has to double as that memory, so it goes
back to being exactly what the current value sizes to — which also
fixes it claiming "4 bytes" for a String whose value box had been
cleared while the request was built at length 1.
Two neighbouring bugs on the same paths:
* promoting an AOB hit to the cheat table created a zero-width entry
(no Length field, and the IDA spec's length is 0), re-read as empty on
every poll tick. It now measures the pattern — one token, one byte;
* an empty String value parsed to a 1-byte NUL buffer, so scanning it
matched every zeroed byte in the target. Byte Array already rejected
its own empty input; both now do.
Follow-up to #79.
…ade them
Third round of the same mistake, in the paths the previous fix didn't
reach. All four take the width from the Length readout, which follows
the Value box — a no-value scan type clears that box outright, and the
user can retype it between scans without rescanning:
* promoting to the cheat table fell through to the spec default: a
4-byte "olá" scan promoted at 16, so the entry pulled 12 bytes of
neighbouring memory into the cell on every poll tick;
* "Increased/Decreased value BY" sit outside NO_VALUE_SCAN_TYPES, so
they missed the previous fix and sized the re-read from the *delta*
text — a 4-byte baseline re-read 1 byte at a time, dropping every
address, and then written back as the new baseline width;
* "Update Values" is a read-only refresh, but re-read at whatever was
in the Value box and moved the baseline to match, overwriting every
stored value with a truncated read;
* the readout ignored the range upper bound while the scan sized with
max(lo, hi), so a "AA".."AA BB CC" range reported 1 byte and ran at 3.
All four now use the width the results were scanned at. _sync_value_length
reads both bounds and no longer needs its callers to check the type.
Also: re-picking "AOB Pattern (IDA)" on a promoted hit reset the entry
to a zero-width buffer (the spec's own length is 0), undoing the pattern
width from the other side; and the empty-value message is neutral now
that it surfaces in the cheat table's write dialogs too.
Follow-up to #79.
…ng it
Fourth round on the same class, so this one names the concept instead of
patching another site: is_sized_by_value(spec) is now a single predicate
the panel uses everywhere it previously spelled out "String or Byte
Array, but not a pattern" by hand — which is what kept producing these.
That gate was wrong in one place already: accepts_length_override is
True for Regex too, so the width override caught it and made its Length
field — the only editable one, and the user's own byte_length — inert
after a first scan. Update Values and promote honour it again.
Update Values was building a full request just to throw the value away
(RefineScanWorker applies no comparison when filter_only is False). With
the new empty-value rejection that turned into a hard regression: a
Value box cleared by a no-value scan type aborted the refresh with
"Invalid value" instead of refreshing. It builds the request directly
now and can't fail.
And the baseline width is adopted when a scan *lands*, not when it is
dispatched: set_has_results is called by the owner on completion, so a
Next Scan whose worker errors out no longer leaves a width the values on
screen were never read at — which the following Changed/Unchanged refine
would have compared against.
Also: bulk edit collapsed AOB entries to a zero-width buffer exactly as
_change_type did before the last commit, and manually adding an AOB
address created one from the start; both ask for or keep a real width.
Follow-up to #79.
Chasing the zero-width AOB entry one call site at a time found two more:
importing a cheat table whose rows were saved by a build that promoted
AOB hits at the spec's 0 (`raw.get("length") or spec.length` is `0 or 0`),
and the pointer-chain dialog, which — unlike the pointer-scan dialog
right next to it — offered the pattern types in its "Read value as"
combo, so `_current_spec()` handed back the spec's 0.
Rather than a fourth guard, the floor now sits in add_entry: every entry
enters the table through it, whichever way it was made — promoted from a
scan, from either pointer dialog, added by hand, or loaded from JSON.
A zero-width entry can't be spotted from the table (the Value column
just reads back empty forever), so this is worth enforcing once at the
boundary rather than trusting each producer.
The pointer-chain dialog also gets the filter its sibling already had,
with the same reasoning: reading a value "as a pattern" is meaningless
when the address is already known.
Follow-up to #79.
…r worked
Last round's baseline fix promoted a pending width on any report of
results, and I reasoned that only a completed scan could produce one.
That was wrong: "Update Values" finishes through the same handler. So a
Next Scan whose worker errored left its width behind, and the next plain
refresh adopted it — 2-byte values on screen, baseline claiming 4, and
the following Changed Value refine drops everything. The pending width is
now discarded when the scan cycle ends (set_busy(False), which the owner
emits after the completion signal), so only the scan that actually landed
can have set it.
"Increased/Decreased Value By" never worked on String / Byte Array: the
comparator does `prev + exp`, which concatenates rather than adds and is
never equal to the fixed-width value read back, while `prev - exp` raises
TypeError that the refine worker swallows into "doesn't match". Every
address was dropped and nothing said why. The combination is rejected
with a message pointing at Changed / Unchanged Value, and last round's
width branch for it goes away — it was sizing a comparison that could
never be true.
Also: the cheat table's "Change buffer length" dialog capped at 1024
while a promoted String / Byte Array entry can be as wide as the value
scanned for, so opening it and pressing OK silently shrank the entry.
Follow-up to #79.
… edges
Giving AOB entries a real width made their Value cell live for the first
time, and it was wired backwards: the cell formats the bytes it reads as
hex, but parses an edit as a *pattern*, so typing "00" wrote ASCII
0x30 0x30 rather than the byte 0x00 — reported as a successful write. A
pattern finds an address; it can't hold a value. Hits promote as Byte
Array at the pattern's width now, so the cell reads and writes the same
thing. The zero-width buffer used to make every such write fail, which
is why this never showed.
Two more edges on the pending-width adoption:
* a cancelled refine still reports results (the worker breaks out of its
loop and emits finished_ok with what it kept), but only the rows it
reached were re-read at the new width. Neither width describes the
table, so cancelling keeps the one most rows were read at;
* a request the owner rejects before the busy cycle begins (its handler
returns early) never reaches set_busy(False), so its width lingered.
"Update Values" now records the width it re-reads at — which is what
the table will hold once it lands, and overwrites anything stale.
Also: the Length field's enable expression still tested a clause that
can no longer be true, reading as if more types than Regex could make it
editable. Regex is the only spec whose width is genuinely the user's.
Follow-up to #79.
Self-review of the whole diff, on the final state rather than commit by
commit. Three things it turned up.
The write-ASCII bug wasn't fixed, only patched at one of its three
doors. Substituting Byte Array inside current_spec_and_length covered
promotion, but adding an address by hand with the AOB type, changing an
existing entry's type to AOB, and importing a JSON table written before
that change all still produced a pattern-typed entry — whose cell
displays hex and writes the ASCII spelling of what you type. A pattern
is search syntax: a way to find an address, never a shape a value is
read and written back as. The type pickers now offer HOLDABLE_TYPE_LABELS
(the non-pattern specs, the same filter the pointer dialogs already use)
and add_entry re-types anything that still arrives — it is the one door
promote, manual add and JSON import all pass through. The substitution
comes out of current_spec_and_length, which was also handing the wrong
spec to "Update Values"; three now-unreachable `or`-guards for the AOB
zero come out with it, since they implied a zero could still arrive.
The delta rejection sat before the pattern short-circuit. Both pattern
specs are bytes-typed, so an AOB scan with a delta type raised instead
of forcing EXACT as the pattern path documents — and pointed the user at
Changed/Unchanged Value, which are disabled in pattern mode. Moved
below, where it means what it says.
Also: "Update Values" carried two comments contradicting each other
about whether it moves the baseline (it does, to the width it re-reads
at), and the Length field's enable rule had two overlapping rationales
left over from when the expression was more than `is_regex`.
Follow-up to #79.
Reverts removing AOB / Regex from the cheat table in favour of fixing
what was actually broken about them.
A cheat entry is read and written every poll tick, so its type has to
round-trip: format(bytes read) must parse back to the same bytes. The
pattern specs failed that because `parse` answers a different question —
"what am I searching for?" — and for an IDA pattern it hands back the
pattern *text*, so a cell displaying `48 8B 00` wrote the ASCII spelling
of that hex.
The specs now answer both questions. `ValueTypeSpec.parse_write` turns
cell text into the bytes to write, and takes the value last read at the
address; `parse_value_for_write` picks it when a spec has one. The
scanner keeps asking `parse_value` — it only ever searches — and the
cheat table only ever writes, so it no longer imports `parse_value` at
all.
For an IDA pattern that means the tokens resolve to the bytes they name,
and `?` keeps the byte already at that offset: `48 8B ? ? 90` over
`48 8B 12 34 00` writes `48 8B 12 34 90`. That mirrors the wildcard's
search meaning and is what makes a signature patchable — you name the
bytes you mean to change and leave the operands alone. A wildcard with
nothing read yet is refused with a message rather than writing a zero.
For a regex, which names a set of byte strings rather than one, the cell
text is taken literally, matching what the cell displays.
`tokenize_pattern` comes out of `compile_pattern` so the search and write
paths share one set of token rules and error messages.
Follow-up to #79.
Self-review of the parse_write commit. The cheat table's bulk edit
stores the width parse_value_for_write returns back onto the entry, and
that width is how many bytes the row *reads* on every poll tick — set by
the user, and none of a write's business.
parse_value honours an explicit length_override for the types that
accept one, so before parse_write existed no value edit ever resized an
entry. The new path returned len(value) unconditionally, and Regex does
accept an override: bulk-editing a 64-byte regex entry to "hi" collapsed
it to 2 bytes, so the row then displayed 2 bytes of the address forever.
It now follows the same override rule as parse_value.
An IDA pattern is unaffected either way — it declines a length override,
so the cheat table never writes the returned width onto the entry — but
it now reports the pattern's own byte count rather than the search
path's 0, which is not a size any buffer could have.
Both pointer dialogs filtered their "Read value as" list by is_pattern,
which lumps together two specs that behave nothing alike once the
address is already known.
An IDA pattern genuinely can't be offered: it declares a length of 0 —
the scanner derives a match's width from the pattern — and refuses a
length override, so the dialog read zero bytes and displayed nothing. A
regex has neither problem: its width comes from the Length field, and it
renders the bytes as text up to the first NUL, which "String (UTF-8)"
does not. Reading b"Player42\0\xff\xfe\x01rest" shows "Player42" as a
regex and "Player42\0��\x01rest" as a string, so it isn't a
duplicate of anything else in the list.
has_readable_width() replaces the is_pattern test and asks the question
that actually matters — can this spec size a read at an address I
already have? — derived from the spec rather than from a label. The
cheat table keeps offering both, since an entry carries its own width.
Pointer Scan had this filter before the branch and Pointer Chain gained
it earlier in it; both were dropping the regex for the same wrong
reason, so both are corrected.
An entry's last_value / frozen_value hold whatever the *previous* spec
decoded, and nothing waits for a fresh poll tick after the type changes,
so the new spec was being applied to the old type's value. Two ways out:
* _rebuild formats it — _fmt_bytes(1234) raises TypeError from inside a
Qt slot, so an Int32 row retyped to Byte Array or AOB left the table
un-rebuilt; a frozen entry would also republish the old value to the
poll worker under the new pytype;
* the bulk edit's "set type" and "set value" run in the same pass, so an
IDA pattern's wildcard reached len() on an int. That raised TypeError
too, which neither call site's `except ValueError` catches.
Both branches now forget the cached value when the label actually
changes — the next tick refills it — and the pattern write path treats a
non-bytes `current` as "nothing read yet", so it stays inside the
ValueError contract its callers handle.
Cancelling a *first* scan no longer discards its width. Both workers
report partial results after a cancel, but only a refine leaves the rows
at mixed widths; every address a first scan found, it found at its own
width, and there is no earlier one to fall back on — so the next
Changed/Unchanged refine went to the spec default, 16 for a String
scanned at 4, which is the failure this branch exists to fix.
_has_results tells the two apart.
Also: an IDA pattern wider than the entry it is typed into was truncated
by prepare_write without a word, while the same edit one byte short was
refused by the wildcard check; it is refused now too. And
current_spec_and_length still documented a re-typing that was reverted
several commits ago.
tokenize_pattern comes back out of util.pattern.__all__ — it exists so
the scan and write paths share token rules, nothing outside the package
consumes it, and neither the guide nor the API reference describes it.
Follow-ups from review of the previous commit, three of them consequences
of it.
Dropping frozen_value on a type change left `frozen` ticked with no
baseline, and nothing re-adopted one: the poll worker skips a frozen
entry whose frozen_value is None, so the Active box stayed on while the
freeze silently never wrote again until the user toggled it. The next
tick now adopts the first value read under the new type.
A value wider than its entry was written short without a word — the cell
kept showing all of it until the next tick corrected itself — because
prepare_write treats the entry width as a hard truncating cap. The
previous commit added exactly this guard for patterns and left String
and Byte Array silently dropping the tail; all three refuse it now.
An IDA pattern's promotion width was re-derived from the Value box,
which stays editable in pattern mode, so editing the pattern after a
scan promoted (or refreshed) at a width the hits were never found at —
the same staleness _last_scan_length exists to prevent. The pattern path
couldn't use it because a pattern request reports a length of 0, which
never promoted; the dispatched width is now the token count.
Also: the wildcard's out-of-range message read as though the entry were
permanently too narrow, when what it measures is the last read, which a
narrower write shortens until the next tick.
…ress
Three misses from the same habit — fixing a case instead of the rule.
add_entry's "address already exists" branch is the third place a
spec_label changes, and the previous commit fixed the other two. Promote
an address already in the table under a different type — from a scan or
from either pointer dialog — and the value the old spec decoded survived
into _rebuild, where the new spec's formatter met it:
_fmt_bytes("hello") raises ValueError from inside add_entry itself.
The width guard was in two places and wrong in both. On the parse_write
path it existed only for IDA patterns, so a regex entry got exactly the
silent truncation the guard was added to stop. And it counted len(value)
— characters for str — against a width that is a byte count, so "ábc"
(3 characters, 4 bytes) passed the check for a 3-byte entry and
prepare_write put a byte through the far wall. There is now one guard,
after the value exists, measuring what actually goes on the wire; it
covers String, Byte Array, Regex and IDA patterns alike.
Not changed: current_spec_and_length still reads the live Length field
for a regex rather than _last_scan_length. That field is the user's
byte_length and the only editable width in the app; an earlier review
flagged the opposite — that preferring the scan width left it inert —
and Next Scan is disabled in pattern mode, so no baseline comparison can
be thrown off by it. Editing it and refreshing is the control working.
Three from review of the previous commit.
Changing a row's type and writing a value in the same bulk edit failed
every selected row: the type change forgets the cached value, and the
write in the same iteration then had nothing for an IDA '?' to keep —
"can't be used before the value has been read at least once", about
bytes that were sitting right there. Byte Array → AOB doesn't change
what those bytes mean, and doing the two steps separately worked, which
is the tell. The write now captures the value before the forget runs.
Forgetting stays unconditional: a switch that keeps the pytype can still
change the width (Int32 → Int16), and parse_value_for_write already
ignores a `current` the new type could not have produced.
"Change buffer length" capped at max(1024, entry.length), so an entry
already wider than 1024 — which is normal now that entries are promoted
at the width of the value scanned for — could only ever be shrunk, while
a wider value couldn't be written into it either, since the width guard
refuses that. No way out from inside the app. It uses the same ceiling
the scanner does.
And the Type column showed the width only for specs that accept a length
override, which excludes the IDA pattern — the one spec whose width this
branch made real and enforced. Being told to widen an entry whose width
is nowhere on screen isn't much of an instruction.
Three dead ends the previous commit's guards created, all found by
review of it.
A bulk edit that retypes rows to a wider type refused every one of them.
Three Int32 rows retyped to String with "hello" hit "the entry holds 4 —
widen the entry first", and there is no width field in the bulk dialog,
nor a "change length" on a multi-row selection: no way out from inside
the app. A retype to a variable-width spec now takes the width from the
value it writes, which is what promotion does too.
The width ceiling went from a too-tight 1024 to no bound at all, and the
poll worker allocates the entry's width for every row on every 100 ms
tick — one typo away from a 2 GB allocation the tick's blanket except
swallows and retries forever. MAX_ENTRY_LENGTH bounds it at a megabyte,
far past any value a scan produces, and the manual-add dialog uses the
same number instead of disagreeing at 1024.
Re-arming a frozen row on the next tick after a type change traded a
no-op for a wrong write: with frozen_value gone and the box still
ticked, the tick adopted whatever the address happened to hold and the
app started pinning a value the user never chose. The type change
releases the freeze now. The re-arm stays for the case it was written
for — a box ticked before the first read, which is a deliberate action
on an unchanged type.
Not changed: a regex cell writes its text literally, so editing one that
held non-UTF-8 bytes writes U+FFFD back and the old tail survives past
the new text. That is how String (UTF-8) has always behaved — reads
decode with errors="replace" and prepare_write never pads — so it is a
property of writing text over binary, not of making regex writable.
Changing it means changing what a string write means.
@JeanExtreme002
JeanExtreme002 merged commit 7c7f80c into mainAug 28, 2026
13 checks passed
@github-actions
github-actionsBot deleted the fix/byte-array-infers-length branch August 28, 2026 20:50
@JeanExtreme002JeanExtreme002 mentioned this pull request Aug 28, 2026
2 tasks
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

appGUI application changes (PyMemoryEditor/app/)libLibrary changes (PyMemoryEditor/)testsTest changes (tests/)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Byte array search shouldn't need to specify length

1 participant

@JeanExtreme002
, '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

fix(app): infer the byte-array scan width from the value entered - #87

Merged
JeanExtreme002 merged 19 commits into
mainfrom
fix/byte-array-infers-length
Aug 28, 2026
Merged

fix(app): infer the byte-array scan width from the value entered#87
JeanExtreme002 merged 19 commits into
mainfrom
fix/byte-array-infers-length

Conversation

@JeanExtreme002

@JeanExtreme002JeanExtreme002 commented Aug 27, 2026

Copy link
Copy Markdown
Owner

Why is this PR necessary, what does it do?

Fixes#79, and the run of related width bugs that surfaced while doing it.

The reported bug

Scanning for a Byte Array (Hex) required the user to keep the Length field
in sync by hand with the bytes they typed, and got both failure modes wrong:

  • a value wider than Length was squeezed into a fixed-width ctypes buffer
    and the scan died with ValueError: byte string too long. The default Length
    is 4, so any 5-byte value hit this — exactly what Byte array search shouldn't need to specify length #79 reports;
  • a value narrower than Length was NUL-padded, silently turning the scan
    into "these bytes followed by zeros": no error, just a scan that finds
    nothing and never says why.

String (UTF-8) already derived its width from the typed text, and the library
itself infers it in resolve_bufflength_for_value — the scanner panel was the
only place still asking the user to count. Byte Array now follows the same rule,
and the Length field became a read-only readout of what the value sizes to.
Partial matching keeps its own value type (AOB Pattern (IDA)).

One width, one source

Fixing that exposed the same mistake in every other place that needed "the width
of the rows currently on screen" and read the Length field instead — which
follows the Value box, and the user can retype that between scans:

  • promote to cheat table created entries at the wrong width (a 4-byte olá
    scan promoted at the spec's 16, so the cell pulled 12 bytes of neighbouring
    memory in on every poll tick);
  • the no-value comparisons (Changed / Unchanged / Increased / Decreased)
    re-read at a width the baseline was never recorded at, so every address
    reported as changed;
  • Update Values re-read at whatever was in the Value box, overwriting every
    stored value with a truncated read — and aborted outright with
    "Invalid value" if that box happened to be empty;
  • the readout ignored a range's upper bound while the scan sized with
    max(lo, hi).

They all take it from _last_scan_length now — the width of the scan that
produced the rows — which is adopted when a scan lands, not when it is
dispatched, so one that errors, is cancelled, or is rejected by its handler
can't leave a width behind for a later refresh to pick up. is_sized_by_value()
replaced the "String or Byte Array, but not a pattern" test that was being
spelled out by hand at each site, which is what kept producing these.

Pattern types now round-trip as cheat entries

Giving AOB entries a real width made their cheat-table cell editable for the
first time, and it was wired backwards: the cell formats what it reads as hex
but parsed an edit as a pattern, so typing 00 wrote ASCII 0x30 0x30 into
the target and reported success.

The cause was the spec answering one question. spec.parse answers the
scanner's — "what am I searching for?" — and for an IDA pattern that is the
pattern text, which is not a value any address can hold. A cheat entry is
read and written every poll tick, so its type has to round-trip:
format(bytes read) must parse back to the same bytes.

The specs answer both questions now. ValueTypeSpec.parse_write turns cell text
into the bytes to write and receives the value last read at the address;
parse_value_for_write picks it when a spec has one. The scanner keeps asking
parse_value — it only ever searches — and the cheat table only ever writes, so
it no longer imports parse_value at all.

For an IDA pattern the tokens resolve to the bytes they name, and ? keeps the
byte already at that offset:

cell shows 48 8B 12 34 00
edit "48 8B 90 90 00" → writes 48 8B 90 90 00
edit "48 8B ? ? 90" → writes 48 8B 12 34 90 (the ?s are preserved)

That mirrors the wildcard's search meaning and is what makes a signature
patchable — you name the bytes you mean to change and leave the operands alone,
the same semantics Cheat Engine uses. A wildcard with nothing read yet is
refused with a message rather than writing a zero. A regex names a set of byte
strings rather than one, so there the cell text is taken literally, matching what
the cell displays. tokenize_pattern comes out of compile_pattern so the
search and write paths share one set of token rules and error messages.

Writing a value must not resize the entry that holds it, either. parse_value
honours an explicit length override, so before this no value edit ever changed
an entry's width; the new write path returned len(value) unconditionally, and
Regex does accept an override, so bulk-editing a 64-byte regex entry to "hi"
collapsed it to 2 bytes and the row displayed 2 bytes of the address from then
on. The write path follows the same override rule as the search path now.

Neighbouring, same cause: every path that fell back to the AOB spec's declared
length of 0 — promotion, adding an address by hand, changing an entry's type,
importing a JSON table — minted a zero-width entry that read back empty on every
tick. add_entry floors the width, since it is the one door all of those pass
through.

The pointer dialogs keep the regex type

Both dialogs read a value at an address the user already has, and both filtered
their "Read value as" list by is_pattern — which lumps together two specs that
behave nothing alike once the address is known.

An IDA pattern genuinely can't be offered there: it declares a length of 0 (the
scanner derives a match's width from the pattern) and refuses a length
override, so the dialog read zero bytes and displayed nothing. A regex has
neither problem — its width comes from the Length field, and it renders bytes as
text up to the first NUL, which String (UTF-8) does not:

memory: b"Player42\0\xff\xfe\x01rest"
Regex (String) → "Player42"
String (UTF-8) → "Player42\0\ufffd\ufffd\x01rest"

So it isn't a duplicate of anything else in the list. has_readable_width()
replaces the is_pattern test and asks what actually matters — can this spec
size a read at an address I already have? — derived from the spec rather than
from a label. Pointer Scan had the old filter before this branch and Pointer
Chain gained it during it; both were dropping the regex for the same wrong
reason, so both are corrected. The cheat table keeps offering both types, since
an entry carries its own width.

Comparisons that never worked

Increased/Decreased Value By on String / Byte Array: the comparator does
prev + exp, which concatenates rather than adds and is never equal to the
fixed-width value read back, while prev - exp raises TypeError that the
refine worker swallows into "doesn't match". Every address was dropped in
silence. The combination is rejected with a message pointing at Changed /
Unchanged Value. An empty String value likewise sized to a 1-byte NUL
buffer, so scanning for it matched every zeroed byte in the target; Byte Array
already rejected its own empty input.

Checklist (complete all items):

  • Added tests as necessary.
  • There is no breaking change for existing features.

References:

Closes#79

Notes:

Reproduction of the reported bug, and the same call after the fix:

build_scan_request(BYTES, EXACT_VALUE, value_text='00 11 22 AA BB CC', length_spin_value=4)
# before → length=4 → value_to_bytes(...) → ValueError: byte string too long# after → length=6 → value_to_bytes(...) → b'\x00\x11"\xaa\xbb\xcc'

Deliberately left alone: for VALUE_BETWEEN with endpoints of different widths
the shorter one is still NUL-padded up to the wider. That is documented library
behaviour (resolve_bufflength_for_value), it is what makes a fixed-width
comparison possible when the user gives two different widths, and str has
always behaved the same way.

Suite goes from 486 to 520 passing with no new failures; make lint is clean and
make type-check reports the same pre-existing errors as main (none in the
files touched). The 15 still-failing tests are unrelated to this branch: they are the macOS
KERN_MEMORY_ERROR scan abort fixed by #88, and they fail the same way on
main. With both branches merged locally the whole suite passes — 538 tests,
no failures — which is the first time the real memory read/write path has been
exercised here at all.

Scanning for a Byte Array (Hex) required the user to keep the Length
field in sync with the bytes they typed, and got both failure modes
wrong when they didn't:
* a value wider than Length was squeezed into a fixed-width ctypes
buffer and the scan died with "ValueError: byte string too long"
(the default Length is 4, so any 5-byte value hit this);
* a value narrower than Length was NUL-padded, silently turning the
scan into "these bytes followed by zeros" with no feedback.
String (UTF-8) already derived its width from the typed text, and the
library itself infers it in resolve_bufflength_for_value — the scanner
panel was the only place still asking. Byte Array now follows the same
rule: the Length field becomes a read-only readout of the parsed byte
count, and build_scan_request lets parse_value size the buffer. Partial
matching keeps its own value type (AOB Pattern).
This also fixes promoting byte-array results to the cheat table, which
read the same spin box and so created entries truncated to 4 bytes.
The no-value comparisons (Increased / Changed / …) still read the
Length field: they carry no value to measure, and the readout holds the
width the previous scan settled on.
Closes#79
@github-actionsgithub-actionsBot added app GUI application changes (PyMemoryEditor/app/) lib Library changes (PyMemoryEditor/) tests Test changes (tests/) labels Aug 27, 2026
Two problems in the same neighbourhood, surfaced by review of the
byte-array fix.
The no-value comparisons (Increased / Changed / …) carry no value to
measure, so they read the Length field — but str was excluded and fell
back to the spec default of 16. Picking "Changed Value" after a 4-byte
"olá" scan refined it by re-reading 16 bytes per address, so the value
read back ("olá\0\0…") never equalled the "olá" the first scan had
recorded and every address reported as Changed. The readout now holds
its last valid width when the value field is cleared (it already did
for byte arrays), and the no-value branch honours it for both types.
Promoting such a row to the cheat table was hitting the same 1-byte
readout and is fixed with it.
Switching value type left the readout showing a width computed for the
previous type when the value doesn't parse as the new one — 21 bytes of
string carried over into Byte Array, for instance. That field is
read-only now, so the user had no way to correct it. Selecting a
value-sized type seeds the spec default before syncing.
Follow-up to #79.
Opening the app lands on Int32, whose Length reads 4. Picking Byte
Array (Hex) kept showing that 4 — it was never a width the byte-array
scan would use — and the first hex digit typed dropped it to 1, so the
one number the field ever showed on its own was wrong.
String and Byte Array take their width from the value, and the field is
read-only for them, so there is nothing to show and no way for the user
to correct whatever we invent. The spin now opens a 0 slot rendered as
"— (set by the value)" for that state, and fills in a real number as
soon as the value sizes to one. The two consumers that could see the 0
(the no-value next-scan width and the promote-to-cheat-table length)
fall back to the spec default.
Selecting any other type clears the special-value text and restores the
minimum of 1, so a 1-byte Int8 shows "1 bytes" rather than landing on
the empty-slot rendering.
Follow-up to #79.
…ded with
Increased / Decreased / Changed / Unchanged compare against the values
the previous scan recorded, so they have to re-read at that scan's
width. They were reading the Length field instead, which tracks the
value currently in the Value box — and the user can edit that between
scans without rescanning. First Scan for the byte array `00 11`, type
`00 11 22 33`, pick "Changed Value", Next Scan: every address is
re-read 4 bytes wide, compared against a 2-byte baseline, and reported
as Changed. "Update Values" was worse, writing the mismatched reads
back over the baseline for every later comparison.
The panel now records the width of each scan it emits and passes it as
previous_scan_length; it's dropped along with the results it describes.
The Length readout no longer has to double as that memory, so it goes
back to being exactly what the current value sizes to — which also
fixes it claiming "4 bytes" for a String whose value box had been
cleared while the request was built at length 1.
Two neighbouring bugs on the same paths:
* promoting an AOB hit to the cheat table created a zero-width entry
(no Length field, and the IDA spec's length is 0), re-read as empty on
every poll tick. It now measures the pattern — one token, one byte;
* an empty String value parsed to a 1-byte NUL buffer, so scanning it
matched every zeroed byte in the target. Byte Array already rejected
its own empty input; both now do.
Follow-up to #79.
…ade them
Third round of the same mistake, in the paths the previous fix didn't
reach. All four take the width from the Length readout, which follows
the Value box — a no-value scan type clears that box outright, and the
user can retype it between scans without rescanning:
* promoting to the cheat table fell through to the spec default: a
4-byte "olá" scan promoted at 16, so the entry pulled 12 bytes of
neighbouring memory into the cell on every poll tick;
* "Increased/Decreased value BY" sit outside NO_VALUE_SCAN_TYPES, so
they missed the previous fix and sized the re-read from the *delta*
text — a 4-byte baseline re-read 1 byte at a time, dropping every
address, and then written back as the new baseline width;
* "Update Values" is a read-only refresh, but re-read at whatever was
in the Value box and moved the baseline to match, overwriting every
stored value with a truncated read;
* the readout ignored the range upper bound while the scan sized with
max(lo, hi), so a "AA".."AA BB CC" range reported 1 byte and ran at 3.
All four now use the width the results were scanned at. _sync_value_length
reads both bounds and no longer needs its callers to check the type.
Also: re-picking "AOB Pattern (IDA)" on a promoted hit reset the entry
to a zero-width buffer (the spec's own length is 0), undoing the pattern
width from the other side; and the empty-value message is neutral now
that it surfaces in the cheat table's write dialogs too.
Follow-up to #79.
…ng it
Fourth round on the same class, so this one names the concept instead of
patching another site: is_sized_by_value(spec) is now a single predicate
the panel uses everywhere it previously spelled out "String or Byte
Array, but not a pattern" by hand — which is what kept producing these.
That gate was wrong in one place already: accepts_length_override is
True for Regex too, so the width override caught it and made its Length
field — the only editable one, and the user's own byte_length — inert
after a first scan. Update Values and promote honour it again.
Update Values was building a full request just to throw the value away
(RefineScanWorker applies no comparison when filter_only is False). With
the new empty-value rejection that turned into a hard regression: a
Value box cleared by a no-value scan type aborted the refresh with
"Invalid value" instead of refreshing. It builds the request directly
now and can't fail.
And the baseline width is adopted when a scan *lands*, not when it is
dispatched: set_has_results is called by the owner on completion, so a
Next Scan whose worker errors out no longer leaves a width the values on
screen were never read at — which the following Changed/Unchanged refine
would have compared against.
Also: bulk edit collapsed AOB entries to a zero-width buffer exactly as
_change_type did before the last commit, and manually adding an AOB
address created one from the start; both ask for or keep a real width.
Follow-up to #79.
Chasing the zero-width AOB entry one call site at a time found two more:
importing a cheat table whose rows were saved by a build that promoted
AOB hits at the spec's 0 (`raw.get("length") or spec.length` is `0 or 0`),
and the pointer-chain dialog, which — unlike the pointer-scan dialog
right next to it — offered the pattern types in its "Read value as"
combo, so `_current_spec()` handed back the spec's 0.
Rather than a fourth guard, the floor now sits in add_entry: every entry
enters the table through it, whichever way it was made — promoted from a
scan, from either pointer dialog, added by hand, or loaded from JSON.
A zero-width entry can't be spotted from the table (the Value column
just reads back empty forever), so this is worth enforcing once at the
boundary rather than trusting each producer.
The pointer-chain dialog also gets the filter its sibling already had,
with the same reasoning: reading a value "as a pattern" is meaningless
when the address is already known.
Follow-up to #79.
…r worked
Last round's baseline fix promoted a pending width on any report of
results, and I reasoned that only a completed scan could produce one.
That was wrong: "Update Values" finishes through the same handler. So a
Next Scan whose worker errored left its width behind, and the next plain
refresh adopted it — 2-byte values on screen, baseline claiming 4, and
the following Changed Value refine drops everything. The pending width is
now discarded when the scan cycle ends (set_busy(False), which the owner
emits after the completion signal), so only the scan that actually landed
can have set it.
"Increased/Decreased Value By" never worked on String / Byte Array: the
comparator does `prev + exp`, which concatenates rather than adds and is
never equal to the fixed-width value read back, while `prev - exp` raises
TypeError that the refine worker swallows into "doesn't match". Every
address was dropped and nothing said why. The combination is rejected
with a message pointing at Changed / Unchanged Value, and last round's
width branch for it goes away — it was sizing a comparison that could
never be true.
Also: the cheat table's "Change buffer length" dialog capped at 1024
while a promoted String / Byte Array entry can be as wide as the value
scanned for, so opening it and pressing OK silently shrank the entry.
Follow-up to #79.
… edges
Giving AOB entries a real width made their Value cell live for the first
time, and it was wired backwards: the cell formats the bytes it reads as
hex, but parses an edit as a *pattern*, so typing "00" wrote ASCII
0x30 0x30 rather than the byte 0x00 — reported as a successful write. A
pattern finds an address; it can't hold a value. Hits promote as Byte
Array at the pattern's width now, so the cell reads and writes the same
thing. The zero-width buffer used to make every such write fail, which
is why this never showed.
Two more edges on the pending-width adoption:
* a cancelled refine still reports results (the worker breaks out of its
loop and emits finished_ok with what it kept), but only the rows it
reached were re-read at the new width. Neither width describes the
table, so cancelling keeps the one most rows were read at;
* a request the owner rejects before the busy cycle begins (its handler
returns early) never reaches set_busy(False), so its width lingered.
"Update Values" now records the width it re-reads at — which is what
the table will hold once it lands, and overwrites anything stale.
Also: the Length field's enable expression still tested a clause that
can no longer be true, reading as if more types than Regex could make it
editable. Regex is the only spec whose width is genuinely the user's.
Follow-up to #79.
Self-review of the whole diff, on the final state rather than commit by
commit. Three things it turned up.
The write-ASCII bug wasn't fixed, only patched at one of its three
doors. Substituting Byte Array inside current_spec_and_length covered
promotion, but adding an address by hand with the AOB type, changing an
existing entry's type to AOB, and importing a JSON table written before
that change all still produced a pattern-typed entry — whose cell
displays hex and writes the ASCII spelling of what you type. A pattern
is search syntax: a way to find an address, never a shape a value is
read and written back as. The type pickers now offer HOLDABLE_TYPE_LABELS
(the non-pattern specs, the same filter the pointer dialogs already use)
and add_entry re-types anything that still arrives — it is the one door
promote, manual add and JSON import all pass through. The substitution
comes out of current_spec_and_length, which was also handing the wrong
spec to "Update Values"; three now-unreachable `or`-guards for the AOB
zero come out with it, since they implied a zero could still arrive.
The delta rejection sat before the pattern short-circuit. Both pattern
specs are bytes-typed, so an AOB scan with a delta type raised instead
of forcing EXACT as the pattern path documents — and pointed the user at
Changed/Unchanged Value, which are disabled in pattern mode. Moved
below, where it means what it says.
Also: "Update Values" carried two comments contradicting each other
about whether it moves the baseline (it does, to the width it re-reads
at), and the Length field's enable rule had two overlapping rationales
left over from when the expression was more than `is_regex`.
Follow-up to #79.
Reverts removing AOB / Regex from the cheat table in favour of fixing
what was actually broken about them.
A cheat entry is read and written every poll tick, so its type has to
round-trip: format(bytes read) must parse back to the same bytes. The
pattern specs failed that because `parse` answers a different question —
"what am I searching for?" — and for an IDA pattern it hands back the
pattern *text*, so a cell displaying `48 8B 00` wrote the ASCII spelling
of that hex.
The specs now answer both questions. `ValueTypeSpec.parse_write` turns
cell text into the bytes to write, and takes the value last read at the
address; `parse_value_for_write` picks it when a spec has one. The
scanner keeps asking `parse_value` — it only ever searches — and the
cheat table only ever writes, so it no longer imports `parse_value` at
all.
For an IDA pattern that means the tokens resolve to the bytes they name,
and `?` keeps the byte already at that offset: `48 8B ? ? 90` over
`48 8B 12 34 00` writes `48 8B 12 34 90`. That mirrors the wildcard's
search meaning and is what makes a signature patchable — you name the
bytes you mean to change and leave the operands alone. A wildcard with
nothing read yet is refused with a message rather than writing a zero.
For a regex, which names a set of byte strings rather than one, the cell
text is taken literally, matching what the cell displays.
`tokenize_pattern` comes out of `compile_pattern` so the search and write
paths share one set of token rules and error messages.
Follow-up to #79.
Self-review of the parse_write commit. The cheat table's bulk edit
stores the width parse_value_for_write returns back onto the entry, and
that width is how many bytes the row *reads* on every poll tick — set by
the user, and none of a write's business.
parse_value honours an explicit length_override for the types that
accept one, so before parse_write existed no value edit ever resized an
entry. The new path returned len(value) unconditionally, and Regex does
accept an override: bulk-editing a 64-byte regex entry to "hi" collapsed
it to 2 bytes, so the row then displayed 2 bytes of the address forever.
It now follows the same override rule as parse_value.
An IDA pattern is unaffected either way — it declines a length override,
so the cheat table never writes the returned width onto the entry — but
it now reports the pattern's own byte count rather than the search
path's 0, which is not a size any buffer could have.
Both pointer dialogs filtered their "Read value as" list by is_pattern,
which lumps together two specs that behave nothing alike once the
address is already known.
An IDA pattern genuinely can't be offered: it declares a length of 0 —
the scanner derives a match's width from the pattern — and refuses a
length override, so the dialog read zero bytes and displayed nothing. A
regex has neither problem: its width comes from the Length field, and it
renders the bytes as text up to the first NUL, which "String (UTF-8)"
does not. Reading b"Player42\0\xff\xfe\x01rest" shows "Player42" as a
regex and "Player42\0��\x01rest" as a string, so it isn't a
duplicate of anything else in the list.
has_readable_width() replaces the is_pattern test and asks the question
that actually matters — can this spec size a read at an address I
already have? — derived from the spec rather than from a label. The
cheat table keeps offering both, since an entry carries its own width.
Pointer Scan had this filter before the branch and Pointer Chain gained
it earlier in it; both were dropping the regex for the same wrong
reason, so both are corrected.
An entry's last_value / frozen_value hold whatever the *previous* spec
decoded, and nothing waits for a fresh poll tick after the type changes,
so the new spec was being applied to the old type's value. Two ways out:
* _rebuild formats it — _fmt_bytes(1234) raises TypeError from inside a
Qt slot, so an Int32 row retyped to Byte Array or AOB left the table
un-rebuilt; a frozen entry would also republish the old value to the
poll worker under the new pytype;
* the bulk edit's "set type" and "set value" run in the same pass, so an
IDA pattern's wildcard reached len() on an int. That raised TypeError
too, which neither call site's `except ValueError` catches.
Both branches now forget the cached value when the label actually
changes — the next tick refills it — and the pattern write path treats a
non-bytes `current` as "nothing read yet", so it stays inside the
ValueError contract its callers handle.
Cancelling a *first* scan no longer discards its width. Both workers
report partial results after a cancel, but only a refine leaves the rows
at mixed widths; every address a first scan found, it found at its own
width, and there is no earlier one to fall back on — so the next
Changed/Unchanged refine went to the spec default, 16 for a String
scanned at 4, which is the failure this branch exists to fix.
_has_results tells the two apart.
Also: an IDA pattern wider than the entry it is typed into was truncated
by prepare_write without a word, while the same edit one byte short was
refused by the wildcard check; it is refused now too. And
current_spec_and_length still documented a re-typing that was reverted
several commits ago.
tokenize_pattern comes back out of util.pattern.__all__ — it exists so
the scan and write paths share token rules, nothing outside the package
consumes it, and neither the guide nor the API reference describes it.
Follow-ups from review of the previous commit, three of them consequences
of it.
Dropping frozen_value on a type change left `frozen` ticked with no
baseline, and nothing re-adopted one: the poll worker skips a frozen
entry whose frozen_value is None, so the Active box stayed on while the
freeze silently never wrote again until the user toggled it. The next
tick now adopts the first value read under the new type.
A value wider than its entry was written short without a word — the cell
kept showing all of it until the next tick corrected itself — because
prepare_write treats the entry width as a hard truncating cap. The
previous commit added exactly this guard for patterns and left String
and Byte Array silently dropping the tail; all three refuse it now.
An IDA pattern's promotion width was re-derived from the Value box,
which stays editable in pattern mode, so editing the pattern after a
scan promoted (or refreshed) at a width the hits were never found at —
the same staleness _last_scan_length exists to prevent. The pattern path
couldn't use it because a pattern request reports a length of 0, which
never promoted; the dispatched width is now the token count.
Also: the wildcard's out-of-range message read as though the entry were
permanently too narrow, when what it measures is the last read, which a
narrower write shortens until the next tick.
…ress
Three misses from the same habit — fixing a case instead of the rule.
add_entry's "address already exists" branch is the third place a
spec_label changes, and the previous commit fixed the other two. Promote
an address already in the table under a different type — from a scan or
from either pointer dialog — and the value the old spec decoded survived
into _rebuild, where the new spec's formatter met it:
_fmt_bytes("hello") raises ValueError from inside add_entry itself.
The width guard was in two places and wrong in both. On the parse_write
path it existed only for IDA patterns, so a regex entry got exactly the
silent truncation the guard was added to stop. And it counted len(value)
— characters for str — against a width that is a byte count, so "ábc"
(3 characters, 4 bytes) passed the check for a 3-byte entry and
prepare_write put a byte through the far wall. There is now one guard,
after the value exists, measuring what actually goes on the wire; it
covers String, Byte Array, Regex and IDA patterns alike.
Not changed: current_spec_and_length still reads the live Length field
for a regex rather than _last_scan_length. That field is the user's
byte_length and the only editable width in the app; an earlier review
flagged the opposite — that preferring the scan width left it inert —
and Next Scan is disabled in pattern mode, so no baseline comparison can
be thrown off by it. Editing it and refreshing is the control working.
Three from review of the previous commit.
Changing a row's type and writing a value in the same bulk edit failed
every selected row: the type change forgets the cached value, and the
write in the same iteration then had nothing for an IDA '?' to keep —
"can't be used before the value has been read at least once", about
bytes that were sitting right there. Byte Array → AOB doesn't change
what those bytes mean, and doing the two steps separately worked, which
is the tell. The write now captures the value before the forget runs.
Forgetting stays unconditional: a switch that keeps the pytype can still
change the width (Int32 → Int16), and parse_value_for_write already
ignores a `current` the new type could not have produced.
"Change buffer length" capped at max(1024, entry.length), so an entry
already wider than 1024 — which is normal now that entries are promoted
at the width of the value scanned for — could only ever be shrunk, while
a wider value couldn't be written into it either, since the width guard
refuses that. No way out from inside the app. It uses the same ceiling
the scanner does.
And the Type column showed the width only for specs that accept a length
override, which excludes the IDA pattern — the one spec whose width this
branch made real and enforced. Being told to widen an entry whose width
is nowhere on screen isn't much of an instruction.
Three dead ends the previous commit's guards created, all found by
review of it.
A bulk edit that retypes rows to a wider type refused every one of them.
Three Int32 rows retyped to String with "hello" hit "the entry holds 4 —
widen the entry first", and there is no width field in the bulk dialog,
nor a "change length" on a multi-row selection: no way out from inside
the app. A retype to a variable-width spec now takes the width from the
value it writes, which is what promotion does too.
The width ceiling went from a too-tight 1024 to no bound at all, and the
poll worker allocates the entry's width for every row on every 100 ms
tick — one typo away from a 2 GB allocation the tick's blanket except
swallows and retries forever. MAX_ENTRY_LENGTH bounds it at a megabyte,
far past any value a scan produces, and the manual-add dialog uses the
same number instead of disagreeing at 1024.
Re-arming a frozen row on the next tick after a type change traded a
no-op for a wrong write: with frozen_value gone and the box still
ticked, the tick adopted whatever the address happened to hold and the
app started pinning a value the user never chose. The type change
releases the freeze now. The re-arm stays for the case it was written
for — a box ticked before the first read, which is a deliberate action
on an unchanged type.
Not changed: a regex cell writes its text literally, so editing one that
held non-UTF-8 bytes writes U+FFFD back and the old tail survives past
the new text. That is how String (UTF-8) has always behaved — reads
decode with errors="replace" and prepare_write never pads — so it is a
property of writing text over binary, not of making regex writable.
Changing it means changing what a string write means.
@JeanExtreme002
JeanExtreme002 merged commit 7c7f80c into mainAug 28, 2026
13 checks passed
@github-actions
github-actionsBot deleted the fix/byte-array-infers-length branch August 28, 2026 20:50
@JeanExtreme002JeanExtreme002 mentioned this pull request Aug 28, 2026
2 tasks
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

appGUI application changes (PyMemoryEditor/app/)libLibrary changes (PyMemoryEditor/)testsTest changes (tests/)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Byte array search shouldn't need to specify length

1 participant

@JeanExtreme002