Skip to content

feat: initial implementation of Picklock - #1

Merged
JeanExtreme002 merged 82 commits into
mainfrom
initial-implementation
Aug 30, 2026
Merged

feat: initial implementation of Picklock#1
JeanExtreme002 merged 82 commits into
mainfrom
initial-implementation

Conversation

@JeanExtreme002

Copy link
Copy Markdown
Owner

A terminal client for PyMemoryEditor: read, write and scan the memory of a
running process from any shell, on Windows, Linux and macOS. No GUI toolkit,
no display, no compiler — one dependency, pure Python.

main is an empty root commit, so this PR is the project.

What is here

40 commands under six namespaces, plus the shell's own:

Namespace Commands
ps: list open close info
memory: read write hex watch regions modules threads alloc free
scan: value next aob regex results keep drop reset
pointer: scan deref read rescan paths save load diff
alias: add list remove
config: list set reset
top level help source version clear exit

That covers the library's surface: every scan comparison, AOB and regex
scanning, pointer-path discovery with the save/rescan/diff workflow that
separates a real path from a coincidence, and typed read/write/watch.

Design notes worth a reviewer's time

  • The help is generated from the parsers. A command's usage line and its
    Options section are built from the very parser that runs it, so a flag that
    exists is a flag that shows. Three commands had already grown options their
    hand-written usage never mentioned; that class of drift is now impossible,
    and tests assert the two agree in both directions.
  • Addresses are expressions[[game.exe+0x1a2b3c]+0x10]+0x8, or #3 for
    a scan result. A #N row is read with the type the scan that found it used,
    not a default, so a byte the scan matched does not come back as a four-byte
    number.
  • Scans are driven a batch of regions at a time rather than handing the
    library the whole map. That is what makes the progress line advance whether
    or not anything is being found, lets Ctrl+C keep partial results, and lets a
    read failure skip one batch instead of losing the scan.
  • Comparisons are flags (--changed, --gt 50), so the value slot only
    ever holds a value: scan:next changed searches for the word.
  • Nothing prints directly; everything goes through one printer, which is
    why colour switches off outside a terminal and why the output is testable.

Verification

  • 914 tests. The fast half (~2 s) covers parsing, dispatch, help, paging,
    aliases and persistence; tests/test_end_to_end.py drives every command
    against a real process
    — the test process itself, so it needs no privileges
    and no second program to launch.
  • Coverage 89%, gated at 80 in CI.
  • flake8 and mypy clean.
  • CI runs on Ubuntu, Windows and macOS across Python 3.10–3.13, and smoke-tests
    the installed console script.

The end-to-end suite paid for itself immediately: it found that the address
parser was splitting module names on hyphens, so _ssl.cpython-311-darwin.so+0
resolved as three subtractions — and a Python target is full of names like that.

Peekmem is a mysql-style interactive shell over PyMemoryEditor: ASCII result
tables, a one-line prompt, no GUI toolkit and no colour beyond a single
highlight for errors, so it runs on a desktop, a headless server or an SSH
session alike. `pip install peekmem && peekmem` is the whole setup —
PyMemoryEditor is a dependency, and the shell itself is stdlib only.

The command set covers the library's surface: process discovery and attach
(ps/open/close/status/info), address-space introspection (regions/modules/
threads), typed read/write/dump/watch, allocate/free, the full scan and refine
cycle including AOB and regex scans, and pointer chains with scanning, saving,
rescanning and cross-run intersection.

Two things are Peekmem's own rather than thin wrappers. Addresses are
expressions — `[[game.exe+0x1a2b3c]+0x10]+0x8`, or `#3` for a scan result — so
a whole pointer chain fits on one line. And scans are driven a batch of regions
at a time so the progress line advances whether or not anything is being found,
and Ctrl+C stops a scan while keeping what it already found.

The same vocabulary runs non-interactively via -e, -f, a trailing command or a
pipe, with results on stdout, errors on stderr and a non-zero exit on failure.
Adds the standard repository furniture, adapted to a CLI client rather than
copied from PyMemoryEditor verbatim:

- Makefile with the usual install/test/lint/build/publish targets, plus `run`
  to launch the shell and `smoke` to drive the CLI as a real process.
- CONTRIBUTING.md documenting the project layout, the two rules that keep its
  shape (commands print through session.printer; anything the user got wrong
  raises CommandError), and how to add a command.
- SECURITY.md scoped to the client: command and expression parsing, `source`
  scripts, `ptrload` files, writes landing where they were reported. Memory
  operations themselves are routed upstream.
- CODE_OF_CONDUCT.md (Contributor Covenant) with the enforcement contact
  filled in.
- Issue templates that ask for `peekmem -e "version"` and whether the session
  was elevated, a PR template, path labels, dependabot and funding config.
- Workflows for PR labelling, conventional-commit title linting and head
  branch cleanup, matching the ones PyMemoryEditor runs.

CI now measures coverage with a `--cov-fail-under=55` gate (the suite sits at
~60%; the gap is command bodies that need a live target it deliberately never
attaches to) and uploads to Codecov as informational only, per codecov.yml.
Every command's flags lived only in hand-written prose, and half the commands
had none at all — so the only way to learn that `scan` takes `--writable` was
to read the source. `help <command>` now prints Arguments and Options sections
built from the very parser the command parses with, which means the two cannot
disagree: adding a flag documents it, and renaming one renames it in both
places at once.

Each command now declares a parser factory alongside its handler, and every
argument carries a `help=` string. `CommandParser` records its actions so the
help can be rendered without reaching into argparse's internals, and the prose
`details` keep only what a flag list cannot say.

`<command> --help` and `-h` do the same thing as `help <command>`, both in the
shell and from the command line (`peekmem scan --help`) — asking a command for
its own help is a reflex worth honouring rather than a spelling to learn. Tab
completion offers a command's real flags after a `-`, and `--help` output wraps
prose to 78 columns while leaving hand-aligned blocks alone.

Four new invariants are enforced in tests: every command declares a parser,
every argument carries help text, every flag appears in that command's help,
and a usage line may not advertise a flag the parser does not accept.
…loor

`scan string utf-8` died with `mach_vm_read_overwrite failed: (os/kern) memory
error (kr=10)`, losing the whole scan. The cause was the PyMemoryEditor
underneath: 2.1.0 does not classify KERN_MEMORY_ERROR as transient, so a
file-backed page whose pager declines to produce data aborts the scan instead
of skipping the chunk. PyMemoryEditor 2.2.0 fixed that (its #88), and Peekmem
already declares `>=2.2.0` — but pip only enforces a floor on an install, not
on a source tree that happens to have an older one importable.

Two changes, because either alone leaves a hole.

A version check at startup turns "a Mach call failed somewhere inside a scan"
into a sentence naming the version installed, the version needed, and the pip
command that fixes it. It is deliberately lenient about unparseable versions:
a fork or a locally patched build should not be refused over a version string.

The scan runner no longer treats a read failure as fatal. A batch the backend
gives up on is skipped, counted, and reported in a note; the rest of the
address space is still walked, and the matches found before the failure are
kept. This mirrors what Ctrl+C already did, for the same reason: a page that
cannot be read says nothing about the thousands of regions behind it, and a
scan that discards four minutes of work over one of them is answering a
question nobody asked. With this, the reported command completes even on the
older library — with a note, and with the coverage gap stated rather than
hidden.
Ctrl+C at the prompt abandoned the line and carried on, leaving Ctrl+D or
'exit' as the only ways out — which is the Python REPL's habit, not the mysql
client's, and not what the keystroke is expected to do. It now ends the
session, exiting 130: the conventional "terminated by SIGINT" status, as
against 0 for 'exit' and Ctrl+D.

Ctrl+C *during* a command keeps its existing meaning — abandon the command,
return to the prompt, keep whatever a scan had already found. Conflating the
two would mean the keystroke that stops a four-minute scan also throws away
its results and the shell holding them. So interrupting costs one keystroke
and leaving costs two.

Verified against a real SIGINT delivered to the process under a pty, in both
states. The banner, 'help', 'help exit' and the README all say so now.
One flat list of 34 words made related commands impossible to find: 'ptrdiff'
and 'paths' sat between 'ps' and 'read' with nothing saying they belong
together. Commands are now colon-separated paths — memory:read, scan:value,
scan:results:keep, pointer:paths:save — so a name says what it acts on and
siblings group themselves.

Nothing costs more to type. Every command keeps a plain-word alias, and the
old name is that alias: 'read', 'scan', 'keep', 'ptrsave' all still work, and
a test enforces that every command has one. The help lists both spellings in
aligned columns, so the alias is discoverable rather than folklore.

A command's group in the help now derives from the first segment of its name
instead of a separate `group=` argument, which removes a field that could
disagree with the naming. Typing a namespace alone — 'memory', 'pointer:' —
lists what is in it, and a parent's help gains a Subcommands section.
'memory read 0x10' is answered by naming the fix ("the command is spelled
'memory:read', with a colon") rather than a bare "unknown command". Tab
completion offers namespaces before it offers forty commands, and the two
namespaces shadowed by an alias ('scan', 'pointer') say so at the end of
their help.

Six new invariants in tests: every command is namespaced, in a declared
namespace, with a plain-word alias that resolves back to it; children share
their parent's prefix and namespace; no namespace is empty; and the help
sections come out in the declared order.
The file pins the local toolchain for asdf, which is a per-machine choice
rather than a property of the project — a contributor on pyenv, or on a
different patch release of 3.11, has no use for this repo's copy. It stays on
disk (asdf needs it to resolve `python3` here at all) and is now ignored,
matching how PyMemoryEditor treats it.

The .gitignore it is ignored by is the fuller one carried over from
PyMemoryEditor, committed here alongside the untracking since the two changes
are the same decision.
Namespacing the commands organised the names but not the help, which still
printed all thirty-four at once — a wall to read past rather than an answer.
'help' now shows one layer: the four subject namespaces with a line each, and
the handful of commands that drive the shell. '<prefix>:help' opens the next
layer down, at any depth — 'scan:help', then 'scan:results:help' — and a
listing points at the layers beneath it instead of printing them.

'<prefix>:help' is a dispatcher convention rather than a registered command,
so it works for every prefix that exists or ever will, without one 'help'
command per namespace cluttering the very listings it exists to print. The
three spellings that reach a listing ('memory', 'memory:help', 'help memory')
all do the same thing; only the second is advertised.

The shell's own commands leave the session namespace and go back to being bare
words: help, set, source, status, version, exit. They are not a subject you go
looking through — they are what you type between doing real work, and burying
them a level down cost more than the tidiness was worth. The registry now
allows a name without a colon, and a test pins down that only those six are
top-level: anything touching the target belongs to a namespace.

`children()` returns one level rather than every descendant, which is what
makes a listing a screen instead of a tree.
Typing a bare namespace already listed it, but only for 'process' and
'memory'. 'scan' and 'pointer' are command aliases as well, so the alias won
and the answer was "the following arguments are required" — a dead end where
the other two were helpful. A bare namespace now lists it in every case.

Nothing is lost by that: both shadowed commands require arguments, so the bare
word could never do anything but fail. The rule applies to the bare word only,
so 'scan int32 100' and 'pointer game.exe+0x10 0x8' are untouched, and
'scan --help' still describes the command rather than the namespace.

'clear' wipes the screen and the scrollback, as the shell's own clear does. It
is deliberately not a session command: the process stays attached and the scan
results survive, because a cleared terminal is not a reset — and 'reset' is the
command for that, said in as many words in the help. It is a no-op when stdout
is redirected, since escape codes in a log file are vandalism rather than
tidying, and it goes through the printer like every other byte Peekmem writes.

Verified in a pty that the real escape sequences reach a real terminal and
that the session survives them.
A namespace listing was a bare table of names and summaries. It now reads like
dokku's plugin help, which packs more into the same screen: a usage line, the
one sentence saying what the namespace is for, a worked example showing real
input and real output, and then the commands — each with the *arguments it
takes*, not just its name.

The signature comes from the usage string the command already declares, so it
cannot drift from the parser; a long one is cut at a token boundary with an
ellipsis rather than pushing every summary off the screen, the full list being
one 'help <command>' away. Sub-namespaces (scan:results, pointer:paths) get the
same page, taking their description and example from the parent command.

'scan:help aob' now describes one command, which is exactly what the listing
header advertises — it prints that command's help rather than running it. The
top-level 'help' takes the same shape: usage line, example, namespaces, then
the shell's own commands.

NAMESPACES becomes a dataclass so a namespace can carry its example alongside
its name and summary, and two usage strings that had grown into prose
('scan:value', 'scan:next') are back to being signatures.
Six commands lived a level deeper than the rest — scan:results:keep,
pointer:paths:save and their siblings — which meant a listing had to be walked
twice to be read once, and every rule about the help needed a paragraph about
the third layer. They are now scan:keep, scan:drop, scan:reset, pointer:save,
pointer:load and pointer:diff, so every command is exactly namespace:command.
The short aliases people actually type (keep, drop, reset, ptrsave, ptrload,
ptrdiff) are unchanged.

The registry now refuses a name with two colons, with a test to hold it there,
and the machinery the third level needed goes with it: the Subcommands section
in a command's help, the "has N subcommands of its own" pointer under a
listing, and the branch in the namespace renderer that fell back to a parent
command for its description and example. `children()` is once again the plain
question it should have been — what is in this namespace.

scan:results:clear becomes scan:reset rather than scan:clear, which also ends
the near-collision with the top-level 'clear' that wipes the terminal.
… ever help

The short aliases had to go: with 'read' meaning memory:read, a second
namespace could never have a 'read' of its own, so the flat vocabulary the
namespacing was meant to end was quietly still there, waiting for the first
collision. Namespaced commands now answer to their full name alone; the
shell's own top-level commands keep their shortcuts (\q, cls, \s), which are
abbreviations rather than second names for something else.

That also settles what a bare namespace does. 'scan' and 'pointer' were
aliases as well as namespaces, so the alias won and 'scan' ran a command. A
namespace now names a subject and never an action: 'scan', 'scan --help',
'scan:help' and 'help scan' all print the same page, byte for byte, and
'scan int32 100' is an error naming the spelling that was meant.

In the top-level help the worked example moves inside the namespaces section,
where it illustrates the thing above it instead of floating over the page. The
sentence about short aliases goes with the aliases, and the line about how to
leave goes too — 'exit' is in the list right above it.

Everything user-facing that named a command by its short spelling now names it
in full: every example, the address and scanning topics, the argument help,
and the errors that tell you which command to run next.
…the parser

Two problems with one cause. Three commands accepted flags their usage line
never mentioned — memory:regions had --shared, scan:value had --outside,
--all-regions and --length, process:open had four — because the line was
written by hand next to a parser that kept growing. It is generated from the
parser now, so a flag that exists is a flag that shows, and a test asserts the
two agree in both directions. The hand-written wording survives where it was
carrying real information, moved into metavars: 'process:open <pid|name>',
'scan:keep <row> [row ...]'.

The listings that report "Showing n of m rows" could only ever show the first
page. process:list, memory:regions, memory:modules, memory:threads and
pointer:paths now take --limit, --offset and --all like scan:results already
did — declared once in add_paging_arguments so the three flags cannot drift
apart in wording or behaviour, with a test pinning that down.

A truncated table now ends with the command for the next page, spelled out:

    Showing 2 of 327 rows (0.01 sec)
    Next page: memory:modules --offset 2 --limit 2

The preview printed after a scan names 'scan:results --offset N' rather than
itself, because re-running a scan to see its second page would be absurd.
…and set to config

'status' and 'version' were two commands answering nearly the same question,
so 'version' absorbs the part worth keeping — Peekmem, PyMemoryEditor, Python
and the platform, in the aligned block 'status' used to print. The session
state 'status' also carried is not lost: the prompt already names the attached
process, and 'ps:info' describes it properly.

The 'process:' namespace becomes 'ps:', matching the name of the tool everyone
already types to list processes, and 'set' becomes 'config', which says which
kind of setting it means. The module moves to ps_commands.py so namespace and
file still line up.

The shell no longer says "Bye" on the way out, and '\q' is gone; 'exit' and
'quit' remain.

Found while renaming: --pid and --name still built an 'open' command line,
which stopped existing when the short aliases went. `peekmem -p 1234` had been
failing with "Unknown command 'open'" — with the right exit status, which is
why the CLI test never noticed. That test now asserts the error is about the
PID, and a new one parses whatever --pid/--name generate through the registry
so a command line that names nothing real fails loudly. A second test pins the
retired spellings — status, \q, \s, set, process:list — as gone for good.
The banner told you how to leave before you had done anything. 'exit' is in
the command list one keystroke away, and the two lines it cost were spent on
the least interesting thing the shell does.
…of them

The reader had to carry a distinction the code needs and they do not: 'memory'
was a namespace, 'clear' a command, and the help had a section for each. Now
there is one list of the words you can type first, and the only thing telling
them apart is the signature — 'memory:COMMAND' says it takes a subcommand
where 'clear' does not, without naming a concept to explain it.

':help' answers for anything at any depth: 'memory:help' lists what memory
takes, 'memory:read:help' describes that one, 'clear:help' and 'version:help'
describe themselves. One rule, no exceptions, and nothing to learn about which
words are which kind. The errors follow: 'memory' now "takes a subcommand"
rather than being "a namespace, not a command".

The word survives in the source, where it is accurate — a name prefix is a
namespace. A test sweeps every help page, every topic, every command's page
and the errors to keep it out of what the reader sees, because a word like
that leaks back one string at a time.
The ':COMMAND' suffix on 'memory' and friends was there to hint that they take
one, but it read like part of the name and made four of the ten lines look
different for a reason nobody asked about. They are listed plainly now; typing
one is how you find out there is more underneath, which is a keystroke rather
than a footnote.
The overview answers "what is there?", and that answer is not improved by
also answering "and what does each one take?" — which is what <command>:help
is for, one keystroke away. The arguments and flags come off, leaving ten
names and ten sentences.

The per-command listings keep their signatures: by the time you have asked
about 'scan' specifically, what its commands take is the next thing you want.
The listing pointed at '<command>:help'. Both spellings work and always will,
but the one named next to a list of bare names should be the one that reads
as a sentence.
Every listing now advertises the same form: the overview, each command's page
and 'peekmem --help' all say 'help <command>'. The ':help' suffix keeps
working at every depth, but a reader should not have to notice there are two
ways to ask.

The placeholder for the second segment is SUBCOMMAND, not COMMAND, and a
listing of them is headed "memory subcommands:" — the distinction is real, and
using the same word for both halves was the thing that made it look like one.

Two tests hold it: 'help <command>' resolves for every registered command, and
every "get help with" line in the shell names that spelling and no other.
--offset made the reader do arithmetic to move on and told them nothing about
where they were. Listings now take --page, counting from 1, and the footer
says which page it is:

    Showing 10 of 25 rows — page 1 of 3 (0.00 sec)
    Next page: scan:results --page 2

"page 1 of 3" is a place you can hold in your head; "--offset 20" is a sum.
The two flags do the same job, so --offset is gone rather than kept beside it.

Asking for a page that does not exist now says how many there are, which beats
an empty table that reads like "no results", and --page 0 is refused with the
rule rather than silently meaning the last page. Result rows keep their
absolute numbers across pages, so '#21' at the top of page 2 is still the row
'memory:read #21' reaches.
'peekmem [game.exe:4242]>' now shows the bracketed part faintly, so a glance
tells you writes are going somewhere without the prompt competing with the
output above it.

Faint rather than a colour: it derives from whatever foreground the terminal
already uses, so it reads as quieter on a light theme and a dark one alike,
and a terminal that does not implement it shows ordinary text — the worst case
is no emphasis rather than an unreadable one. It follows the same switch as
the red ERROR, so a redirected or NO_COLOR session sees plain text.

The escapes are bracketed in \\001/\\002 when readline is driving the line.
Without that, readline counts them toward the prompt's width and puts the
cursor in the wrong column the moment the line wraps or history is recalled —
the classic way a coloured prompt breaks editing. Verified in a pty that the
markers reach readline and not the screen.
'config' was doing three jobs behind one word — print everything, read one
back, assign — told apart by counting arguments, which is how 'config limit'
came to mean "read" while 'config limit 50' meant "write". It is a parent
command now, like the others: bare 'config' prints its page and runs nothing,
'config:list [name]' shows the settings, 'config:set <name> <value>' changes
one. 'config:set name=value' still works as one word.

Reading and writing being different commands lets each say something useful
when it goes wrong: 'config:set limit' now answers "needs a value — to read
one back, use 'config:list limit'" instead of quietly printing the value, and
an unknown name lists the real ones from either side.
A transcript sitting inside a help page is there to be recognised as a
transcript at a glance — the shape of it is the information, not the words.
Dimming the contents separates it from the prose above and the listing below
without adding a rule or a box.

The same faint attribute as the prompt's target, so it stays legible on a
light theme and a dark one, and it follows the same switch: redirected output
carries no escapes at all. The 'Example:' and 'Examples:' labels stay at full
strength, so the block is still findable when skimming.

Dimmed a line at a time rather than one escape around the block: a single span
survives neither a pager nor a terminal that reflows it.
Faint made a transcript hard to read line after line, which is the wrong
trade for a block that exists to be read. Example blocks are grey (bright
black, the conventional secondary-text colour); the prompt's target keeps the
fainter shade, because it only has to be noticed.

Both go through one styling helper, so the readline bracketing and the
colour-off switch stay in a single place.

A caveat worth writing down: grey is an actual colour where faint is derived
from the terminal's own foreground, so on a light-background theme it has less
contrast than faint would. Terminals overwhelmingly default to a dark
background and every other tool greys its secondary text the same way, so this
takes that bet knowingly.
The prompt's target moves off the faint attribute and the example blocks off
bright black; both are now explicit 256-colour greys, 247 for the prompt and
252 for examples — a step lighter each, and still a step apart from each other.

Explicit shades rather than the basic codes, because the basic ones offer no
choice: faint is whatever the terminal makes of the foreground, bright black
is one fixed grey, and "white" is often *exactly* the default foreground,
which would have left the text looking unstyled. Naming the values keeps the
two shades distinct from each other and from ordinary text whatever the theme
does.

A terminal without 256-colour support ignores the parameter and prints plain
text — no emphasis, never a mess — and redirected output still carries no
escapes at all.
Example blocks take the prompt target's colour. They mean the same thing —
context around the output rather than output itself — and two shades implied a
difference that was not there.

So the two styling methods collapse into one. Keeping 'grey' and 'dim' as
separate names for the same escape would have left the distinction in the code
after it stopped existing anywhere else.
Two spaces read as one column; a listing of commands is scanned down its left
edge before any of it is read, and the gap is what stops the two halves
running together into a sentence. Four now, in the top-level listing, in every
subcommand listing, and in 'peekmem --help'.

render_definitions takes the gap as a parameter rather than hardcoding it,
which also moves the continuation indent — a wider gap with the old indent
would leave wrapped descriptions no longer lining up under the first line.

Argument and option lists keep two: there the two columns are read together,
and they never share a page with a command listing.
'alias:add r memory:read' makes 'r' do the same thing, and an alias can carry
arguments: with 'find-text' set to 'scan:value string', 'find-text Peekmem'
runs 'scan:value string Peekmem'. 'alias:list' and 'alias:remove' round it out.

A name already answered to by a command, by one of a command's own shortcuts,
or by another alias is refused rather than shadowing it — nothing you could
type before stops working because of something you added.

The target is checked when the alias is created, not when it is used. That
catches a typo while you still remember what you meant, and it makes chains
impossible by construction: an alias can only point at something the registry
knows, so no alias can point at another. Expansion is therefore a single pass
that always lands on a real command, with no depth limit or cycle check to get
wrong.

Substitution happens before anything else reads the line, so '--help' on an
alias describes what it stands for; 'help <alias>' says what it stands for
first and then prints that command's page. Aliases live for the session, like
the settings — put the lines in a script and 'source' it to get the shell back.
An alias you have to define again every session is not worth defining. They
are now written to disk the moment they change and loaded at startup — the one
thing Peekmem stores, in $XDG_CONFIG_HOME/peekmem/aliases.json (%APPDATA% on
Windows), with PEEKMEM_CONFIG_DIR to move it and 'alias:list' printing the
path.

Settings deliberately still do not persist. They tune one session's output and
a stale one would be a surprise on the next run; a name you chose is the
opposite. The help now says which is which rather than claiming, as it did,
that Peekmem writes no config file at all.

Loading happens in the CLI, not in Session, so a Session built in a test or a
script touches no files unless it asks to — and the suite pins that down with
an autouse fixture that points every test at a throwaway directory, because
remembering to opt in per test is the kind of thing that gets forgotten once
and reads someone's real config.

The file is replaced atomically, so an interrupted write cannot leave a
half-file that the next run would find malformed and drop wholesale. A
malformed or unreadable one loses the aliases and nothing else: refusing to
start over a stray character would be the worse bug. An alias whose command no
longer exists — renamed between releases — is dropped with a line saying so
rather than left to fail later, and a home directory that cannot be written to
is reported without refusing the alias, which still works for that session.
Both are said better elsewhere. The commands are in the reference the docs
generate from the registry, where they carry their arguments instead of being
a list of names that has to be edited by hand every time one is added. The
licence is in LICENSE, declared in pyproject, and shown by GitHub in the
sidebar of the page the README is on.
Keeps the point — every memory operation is the library's — and drops the
list of what the 2.2.0 floor is for.
--max was implemented by writing the max_results setting, so one capped scan
quietly capped every scan for the rest of the session — and 'config:list' then
reported a number the user never chose. The flag's own help calls it an
override of that setting, which is what it now is: the cap is passed to the
scan that carries it and nothing outside that call changes.

The note a truncated scan prints named the setting rather than the cap that
actually stopped it, which was wrong whenever --max was the one in force. It
now names the number it stopped at, and both ways to raise it.
Four end-to-end tests planted a value, scanned with --max, and asserted the
planted address was among the results. That premise only holds while the cap
is never reached first, and on Linux and Windows it was: 'PicklockMarker42'
also exists in this test file's own source, which the interpreter is holding
in memory at lower addresses than the block the test planted, so a cap of 20
was spent before the scan ever got there. test_regex_finds_the_text failed on
both platforms and passed on macOS, which is the address-space layout talking,
not the code.

An assertion about what a scan finds cannot cap the scan. The caps are gone;
what --max does is now tested in tests/test_scanning.py, for what it is.
…re every platform's

- clear_screen: on Windows the printer shells out to 'cls', because not every
  console there has VT processing; the test asserted the escape sequence and
  so could only ever pass on POSIX. It now checks the branch the platform
  actually takes.
- wait_for_enter: on Windows it polls the console through msvcrt, which a pipe
  cannot stand in for. Skipped there, with the reason written down.
- memory:regions: the test hoped the first five regions would include a
  writable one. Which regions come first is the platform's business, and on
  Windows they are not. It now asks for writable regions.

And one that was not about platforms at all: three scan tests asserted
'Empty set' not in the output as a proxy for 'the scan found something'. The
target is this very process, so the phrase is in its memory — Picklock's own
footer text — and a string scan duly found an address that now reads back as
'Empty set', failing the test with a full table of results on screen. The
assertion that the planted address is among the results says the same thing
and cannot collide with the data.
Arguments were lexed with POSIX escaping, so 'source C:\tools\setup.picklock'
became 'C:toolssetup.picklock' — silently, and then the file was blamed for
not existing. Every absolute path on Windows hits this, which made 'source'
and '--export' unusable there for anything but a bare filename.

Escaping is now off. A backslash is a path separator in a shell whose
arguments are mostly paths, and quotes still group, so a path with spaces is
written the same way on every platform. Lines built by cli.py keep working:
shlex.quote wraps them in single quotes, which were already literal.

Two settings of the lexer matter and both are now explicit: '#' introduces a
scan-result row rather than a comment — shlex.split() disables comments for
you, a hand-built shlex.shlex does not, and getting that wrong made every
'memory:read #1' silently lose its argument.
The shadow was 40px of blur offset 18px down, inside a 26px transparent
margin, so it was clipped on every side. Against the white of a README that
reads as a faint rectangle drawn around the picture — announcing exactly where
the image ends, which is the one thing a soft shadow is there to avoid.

It is now shorter and fainter, and the margin is computed from it rather than
guessed at, so the two cannot drift apart again.
Twelve places called the house style the mysql client's. What the reader needs
is the style — a box table, a row count, a timing, no colour — and naming
another program to convey it asks them to know that program, and reads as
borrowed rather than chosen.
The quick start listed one process, which made the filter look like a lookup
and the PID in the next line look arbitrary. Four rows is what 'game' really
matches — the game, a launcher, two helpers — and it earns the line after it:
the PID is used because 'ps:open game' would match four processes and be
refused.

The table is not hand-drawn. It came out of the printer that renders the real
one, with the sort ps:list uses, so the column widths and the row order are
what a reader would see.

Also: both pages said --pid-sort puts the oldest first. Lowest first is what
it does, and PIDs wrap.
Both pages framed help as three layers before saying what to type, which asks
the reader to hold an abstraction in order to learn a keystroke. The guide now
shows the three things you can type and what each gives back, and says the
point plainly afterwards; the quick start just says them in order.

The note about the pages being generated moved to the end and says what that
buys the reader — if a command accepts a flag, the flag is on its help page —
rather than describing the machinery that makes it true.
The table mixed 'help', a bare namespace, and 'help <command>', so the reader
had to notice that the middle row was a different form to learn the same
thing. It is 'help scan' now, and the three rows are one pattern with a longer
argument each time.

The shortcuts are still there and still worth knowing — a namespace on its own,
and --help on any command — so they moved to a line below, where they read as
what they are: conveniences, not the thing to learn first.
The example was a prompt with no output under it, and the sentence after it
asserted 'hundreds of rows' that the reader could not see. The page now shows
the first page — twenty rows, the count, and the line that spells out how to
get the next one — which makes the case for filtering by itself, and shows the
paging footer where the reader first meets it rather than eighty lines later.

Rendered through the printer that renders the real one, so the column widths
and the footer are what a reader would get.
The full listing showed chrome.exe at 1204, 1288 and 1355; every example below
it used 41902 and 41903 — and 41902 is game.exe two rows further up the same
listing, so the page also attached to Chrome at a PID it had just shown to be
something else.

Chrome is 1204, 1288 and 1355 throughout now, which also makes the ambiguous
name example match what the listing shows: three processes, not two. The rest
of the docs use game.exe at 41902 and agree with that listing.
A thread is not memory. It has an id, a state and a priority, and no address —
and cmd_threads never touched the memory map: it asks the process handle for
its threads and prints them. It sat under memory: by association with regions
and modules, which do belong there, since a region is the address space and a
module is a base and a size the address parser resolves against.

ps: already carried thread information anyway — ps:info prints the main
thread's id — so the command that lists them all was the odd one out.

The namespace's summary widens to match what it now holds: finding a process,
attaching to it, and seeing what it is. Nothing is left behind as an alias:
the package is unreleased, so there is no one to keep working, and a
compatibility name for a command that never shipped would be furniture from
the start.
memory:alloc and memory:free had a section on the reading/writing page and
another on the inspecting page, saying the same thing including the same
warning. Allocating is not inspecting — the inspecting page is what is mapped,
what is loaded, what is running — so it stays with the commands that change
the target's memory.
A scan restricted to writable regions searched about a tenth of the address
space, and nothing in the output said so. An hour later, an address that
should have been found is missing and the reason is a flag typed once or a
setting turned on last week. The result set now carries the restriction and
reports it — on the scan, on every refine that narrows it, and again on
scan:results, which is where the question actually gets asked.

It is carried on the result set rather than read from the setting when it is
printed, because the setting can be changed after the scan and the results
would then describe themselves wrongly.

Fixes a bug found while wiring it: --all-regions could not widen a scan when
the writable_only setting was on. The regions handed to the search were chosen
by _run_scan, which read the setting directly, so the flag reached the library
and the library was only ever offered writable regions. With the setting on,
scanning this process for a value found 981 addresses with --all-regions and
1760 after the fix.
A '.picklock' file looks like a format with rules — something you might need a
template for. There is no format: source reads a file of lines and runs each
one, so the extension is free, and .txt says that on sight.

The 'source' example was still 'setup.peek', from before the project was
renamed, and so were two filenames in the shell tests. Those are gone too.
Same page, same sponsor button, same place in the sidebar under Project, so
the two sets of docs behave alike for a reader moving between them.

One line is Picklock's own: a star here is a star PyMemoryEditor earned, since
every read, write and scan is its work. The page is the natural spot to say
so — the reader is already there because they want to give something back.
The page is rebuilt on every docs build, so it always describes the version
being published — saying which one in the first line was a fact the reader
already had from the sidebar.

With the placeholder gone, the version no longer needed importing or passing
to format(), so both go too.
scan --between refused a range written backwards. '--between 5 1' describes
no value, so the scan came back empty — indistinguishable from 'your value is
not in this process', which is the one wrong answer a memory scanner must not
give. Row ranges were already checked this way ('scan:keep 3-1'); values were
not. Both scan:value and scan:next now go through one parser that checks.

memory:watch reported success after failing. A read error was printed and the
loop broke, but the command returned normally, so 'picklock -e "memory:watch
..."' exited 0 on a target that had died — against the exit-code contract the
docs state. The error is now raised after the sample summary, so the samples
still print and the status is still wrong-side-up.

source had no recursion guard. A file that sources itself, or two that source
each other, recursed until RecursionError — which run_line does not catch, so
it took the session and any scan in it down with it. Session.sourcing() tracks
the files being run by real path and refuses re-entry.

An unexpected exception in a command took the whole session with it. By then
a user may hold a scan that took minutes and a pointer scan that took longer,
none of it written down anywhere. The interactive loop now prints the
traceback, says it is a bug worth reporting, and keeps the session; -e, source
and the tests are untouched and still fail loudly.

Also corrects two docstrings that described something other than the code:
scan_regions called its filtering advisory when it decides what is scanned —
which is how --all-regions came to be a no-op — and module_base said prefix
where it matches any substring.
'ps:list --limit -5' reached a Python slice as entries[0:-5], which prints
every row but the last five and reports them as a page — 'Showing 524 of 529
rows'. The setting behind the flag has always refused a negative ('config:set
limit -5'); the flag had no check at all.

Checked in paginate(), so every listing command agrees rather than each
growing its own guard. Zero still means no limit, as the setting documents.
'ps:open --partial chr' left the prompt reading '[chr:41902]', and
'ps:open CHROME.EXE' left it reading the shouted spelling. The prompt carries
the target so a write goes where you think it goes; a fragment of a name is
not a name.

It now asks the OS what the process is called and falls back to the typed name
only where the OS declines to say — which is the case ps:list already renders
as '?'. The lookup was already being done on every attach-by-PID, so it costs
nothing new.
The italic line explained how the screenshot was made, which is the least
interesting thing on the page: a reader deciding whether to install something
does not need the provenance of its picture. The space under a hero image is
where a claim lands hardest, so the claim goes there — one dependency, pure
Python, installs anywhere.

The intro loses the line and reads tighter for it: what it is, where it runs,
how to install.
The table jumped from addresses to pointers, skipping the page about the
commands most people reach for first — reading a value and writing one back.
Placed where the guide places it, between the two.
It offered a way to skip the tests that matter most — the ones that run each
command against a real process — in the two places a newcomer looks first.
'make test' runs everything, which is the only instruction worth putting in
front of someone about to change a command.

The marker itself stays: it is what lets CI and the suite talk about the two
halves, and pyproject documents it for anyone who wants the fast one.
Both in the wording PyMemoryEditor's README uses, so the two projects invite
and licence in the same voice.

The Development block ended by pointing at CONTRIBUTING.md, which the new
section now does a paragraph later; the older pointer goes, so the file is
not asking twice.
@JeanExtreme002
JeanExtreme002 merged commit d7a38ff into main Aug 30, 2026
15 checks passed
@github-actions
github-actions Bot deleted the initial-implementation branch August 30, 2026 22:44
JeanExtreme002 pushed a commit that referenced this pull request Sep 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant