fix(app): infer the byte-array scan width from the value entered - #87
Merged
Conversation
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
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
ctypesbufferand the scan died with
ValueError: byte string too long. The default Lengthis 4, so any 5-byte value hit this — exactly what Byte array search shouldn't need to specify length #79 reports;
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 theonly 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:
oláscan promoted at the spec's 16, so the cell pulled 12 bytes of neighbouring
memory in on every poll tick);
re-read at a width the baseline was never recorded at, so every address
reported as changed;
stored value with a truncated read — and aborted outright with
"Invalid value" if that box happened to be empty;
max(lo, hi).They all take it from
_last_scan_lengthnow — the width of the scan thatproduced 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
00wrote ASCII0x30 0x30intothe target and reported success.
The cause was the spec answering one question.
spec.parseanswers thescanner'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_writeturns cell textinto the bytes to write and receives the value last read at the address;
parse_value_for_writepicks it when a spec has one. The scanner keeps askingparse_value— it only ever searches — and the cheat table only ever writes, soit no longer imports
parse_valueat all.For an IDA pattern the tokens resolve to the bytes they name, and
?keeps thebyte already at that offset:
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_patterncomes out ofcompile_patternso thesearch 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_valuehonours an explicit length override, so before this no value edit ever changed
an entry's width; the new write path returned
len(value)unconditionally, andRegex 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_entryfloors the width, since it is the one door all of those passthrough.
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 thatbehave 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:So it isn't a duplicate of anything else in the list.
has_readable_width()replaces the
is_patterntest and asks what actually matters — can this specsize 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 thefixed-width value read back, while
prev - expraisesTypeErrorthat therefine 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):
References:
Closes#79
Notes:
Reproduction of the reported bug, and the same call after the fix:
Deliberately left alone: for
VALUE_BETWEENwith endpoints of different widthsthe 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-widthcomparison possible when the user gives two different widths, and
strhasalways behaved the same way.
Suite goes from 486 to 520 passing with no new failures;
make lintis clean andmake type-checkreports the same pre-existing errors asmain(none in thefiles touched). The 15 still-failing tests are unrelated to this branch: they are the macOS
KERN_MEMORY_ERRORscan abort fixed by #88, and they fail the same way onmain. 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.