Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions PyMemoryEditor/linux/functions.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
from ..process.thread_info import ThreadInfo
from ..util import (
_validate_pytype,
as_writable_c_buffer,
get_c_type_of,
values_to_bytes,
)
Expand Down Expand Up @@ -378,6 +379,18 @@ def read_process_memory(pid: int, address: int, pytype: Type[T], bufflength: int
return data.value


def read_process_memory_into(pid: int, address: int, buffer) -> int:
"""
Read ``len(buffer)`` bytes from ``address`` directly into the writable
``buffer``, with no intermediate allocation. Returns the number of bytes
read (always the buffer's byte length on success; a short read raises
``_LinuxPartialIOError``).
"""
c_buffer = as_writable_c_buffer(buffer)
size = len(c_buffer)
return _process_vm_readv(pid, addressof(c_buffer), address, size)


def search_addresses_by_value(
pid: int,
pytype: Type[T],
Expand Down
5 changes: 5 additions & 0 deletions PyMemoryEditor/linux/process.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
get_modules,
get_threads,
read_process_memory,
read_process_memory_into,
search_addresses_by_pattern,
search_addresses_by_value,
search_values_by_addresses,
Expand Down Expand Up @@ -117,6 +118,10 @@ def read_process_memory(
self.pid, address, pytype, resolve_bufflength(pytype, bufflength)
)

def read_process_memory_into(self, address: int, buffer: object) -> int:
self.__require_open()
return read_process_memory_into(self.pid, address, buffer)

def search_by_addresses(
self,
pytype: Type[T],
Expand Down
13 changes: 13 additions & 0 deletions PyMemoryEditor/macos/functions.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
from ..process.thread_info import ThreadInfo
from ..util import (
_validate_pytype,
as_writable_c_buffer,
get_c_type_of,
values_to_bytes,
)
Expand Down Expand Up @@ -545,6 +546,18 @@ def read_process_memory(
return data.value


def read_process_memory_into(task: int, address: int, buffer) -> int:
"""
Read ``len(buffer)`` bytes from ``address`` directly into the writable
``buffer``, with no intermediate allocation. Returns the number of bytes
read (always the buffer's byte length on success; a short read raises
``MachPartialReadError``).
"""
c_buffer = as_writable_c_buffer(buffer)
size = len(c_buffer)
return _mach_read(task, address, ctypes.addressof(c_buffer), size)


def write_process_memory(
task: int,
address: int,
Expand Down
5 changes: 5 additions & 0 deletions PyMemoryEditor/macos/process.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
get_task_for_pid,
get_threads,
read_process_memory,
read_process_memory_into,
release_task,
search_addresses_by_pattern,
search_addresses_by_value,
Expand Down Expand Up @@ -292,6 +293,10 @@ def read_process_memory(
self.__task, address, pytype, resolve_bufflength(pytype, bufflength)
)

def read_process_memory_into(self, address: int, buffer: object) -> int:
self.__require_open()
return read_process_memory_into(self.__task, address, buffer)

def write_process_memory(
self,
address: int,
Expand Down
43 changes: 43 additions & 0 deletions PyMemoryEditor/process/abstract.py
Original file line number Diff line number Diff line change
Expand Up @@ -449,6 +449,49 @@ def read_process_memory(
"""
raise NotImplementedError()

@abstractmethod
def read_process_memory_into(self, address: int, buffer: object) -> int:
"""
Read ``len(buffer)`` bytes from ``address`` directly into a
pre-allocated, writable ``buffer`` and return the number of bytes read.

This is the zero-copy counterpart of :meth:`read_process_memory`: where
that method allocates a fresh object on every call, this one fills a
buffer you own and reuse. In a tight polling / recording loop that reads
the same-sized region over and over, reusing one buffer keeps memory use
constant instead of producing a stream of short-lived objects for the
garbage collector to reclaim.

:param address: target memory address (ex: 0x006A9EC0).
:param buffer: any writable, contiguous buffer-protocol object — a
``bytearray``, a ``ctypes`` array, a writable ``memoryview``, a
``numpy`` array, etc. Its byte length determines how many bytes are
read; an element-typed buffer (e.g. a ``numpy`` ``int32`` array) is
sized in **bytes**, not elements. The bytes are written in place; no
decoding is performed (this is the raw-``bytes`` read path). Decode
or reinterpret them yourself afterwards
(``int.from_bytes`` / ``struct.unpack`` / ``numpy`` views / ...).
:return: the number of bytes read — always equal to the buffer's byte
length on success (a short read raises instead, mirroring
:meth:`read_process_memory`).

:raises TypeError: if ``buffer`` is not a writable buffer (e.g. an
immutable ``bytes`` object).
:raises ValueError: if ``buffer`` is empty or not contiguous.
:raises OSError: if the read fails, or returns fewer bytes than
requested (e.g. the range crosses an unreadable/freed page).

Example
-------
::

buffer = bytearray(16)
while recording:
process.read_process_memory_into(addr, buffer)
handle(buffer) # same buffer reused every iteration
"""
raise NotImplementedError()

@abstractmethod
def write_process_memory(
self,
Expand Down
1 change: 1 addition & 0 deletions PyMemoryEditor/util/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
UNSET,
_check_int_fits,
_validate_pytype,
as_writable_c_buffer,
convert_from_byte_array,
get_c_type_of,
prepare_write,
Expand Down
48 changes: 48 additions & 0 deletions PyMemoryEditor/util/convert.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,54 @@ def prepare_write(
return pytype, length, value


def as_writable_c_buffer(buffer: Any) -> "ctypes.Array":
"""
Wrap a writable buffer-protocol object as a ``ctypes`` ``c_char`` array
that shares the same underlying storage, so a backend can read process
memory *directly into the caller's buffer* with no intermediate
allocation. Returning the ``ctypes`` array (rather than a bare address)
keeps a reference to the source alive for as long as the caller holds it,
which is what makes the address safe to pass to the OS read call.

Accepts anything exposing a writable, contiguous buffer — ``bytearray``,
a ``ctypes`` array, a writable ``memoryview``, a ``numpy`` array, etc. The
array's byte length is taken from the buffer itself (``memoryview.nbytes``),
so element-typed buffers like a ``numpy`` ``int32`` array are sized in
bytes, not elements.

:raises TypeError: if ``buffer`` does not support the buffer protocol or is
read-only (e.g. ``bytes`` — use ``bytearray`` instead).
:raises ValueError: if ``buffer`` is empty or not contiguous.
"""
try:
view = memoryview(buffer)
except TypeError:
raise TypeError(
"buffer must support the writable buffer protocol "
"(e.g. bytearray, a ctypes array, or a numpy array), got %s."
% type(buffer).__name__
)

try:
if view.readonly:
raise TypeError(
"buffer must be writable; got a read-only buffer "
"(e.g. bytes). Use bytearray or another writable buffer."
)
if not view.contiguous:
raise ValueError("buffer must be contiguous.")
nbytes = view.nbytes
finally:
# Release the inspection view promptly so it never lingers as an extra
# export on the source object (e.g. blocking a later bytearray resize).
view.release()

if nbytes == 0:
raise ValueError("buffer must have a non-zero length.")

return (ctypes.c_char * nbytes).from_buffer(buffer)


def convert_from_byte_array(
byte_array: ctypes.Array, pytype: Type[T], length: int
) -> T:
Expand Down
35 changes: 35 additions & 0 deletions PyMemoryEditor/win32/functions.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
from ..process.thread_info import ThreadInfo
from ..util import (
_validate_pytype,
as_writable_c_buffer,
get_c_type_of,
values_to_bytes,
)
Expand Down Expand Up @@ -501,6 +502,40 @@ def ReadProcessMemory(
return data.value


def ReadProcessMemoryInto(process_handle: int, address: int, buffer) -> int:
"""
Read ``len(buffer)`` bytes from ``address`` directly into the writable
``buffer``, with no intermediate allocation. Returns the number of bytes
read.

Raises OSError if the read fails or returns fewer bytes than requested
(same partial-read guard as :func:`ReadProcessMemory`).
"""
c_buffer = as_writable_c_buffer(buffer)
size = len(c_buffer)
bytes_read = ctypes.c_size_t(0)

ctypes.set_last_error(0)
success = kernel32.ReadProcessMemory(
process_handle,
ctypes.c_void_p(address),
ctypes.byref(c_buffer),
size,
ctypes.byref(bytes_read),
)

if not success:
_raise_last_error("ReadProcessMemory")

if bytes_read.value != size:
raise OSError(
"ReadProcessMemory partial read at 0x%X: %d of %d bytes read."
% (address, bytes_read.value, size)
)

return bytes_read.value


def _read_region(process_handle: int, address: int, size: int):
"""Read a memory region; returns the byte buffer or None on failure."""
region_data = (ctypes.c_byte * size)()
Expand Down
6 changes: 6 additions & 0 deletions PyMemoryEditor/win32/process.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
GetProcessHandle,
GetThreads,
ReadProcessMemory,
ReadProcessMemoryInto,
SearchAddressesByPattern,
SearchAddressesByValue,
SearchValuesByAddresses,
Expand Down Expand Up @@ -313,6 +314,11 @@ def read_process_memory(
resolve_bufflength(pytype, bufflength),
)

def read_process_memory_into(self, address: int, buffer: object) -> int:
self.__require_open()
self.__require_read()
return ReadProcessMemoryInto(self.__process_handle, address, buffer)

def write_process_memory(
self,
address: int,
Expand Down
17 changes: 17 additions & 0 deletions docs/api/openprocess.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,23 @@ with OpenProcess(
:returns: the original ``value`` you passed in — **not** the truncated/encoded
form actually written (a capped ``str``/``bytes`` write returns the full
original value).

.. py:method:: read_process_memory_into(address, buffer)

Read ``len(buffer)`` raw bytes from ``address`` directly into a
pre-allocated, writable ``buffer`` (no intermediate allocation) — the
zero-copy counterpart of :py:meth:`read_process_memory` for tight
read-the-same-region loops. See :doc:`../guide/read-write` for examples.

:param int address: target memory address.
:param buffer: any writable, contiguous buffer-protocol object
(``bytearray``, ``ctypes`` array, writable ``memoryview``, ``numpy``
array, …). Its byte length sets how many bytes are read; the bytes land
verbatim (no decoding).
:returns: the number of bytes read (the buffer's byte length on success).
:raises TypeError: if ``buffer`` is not a writable buffer (e.g. ``bytes``).
:raises ValueError: if ``buffer`` is empty or not contiguous.
:raises OSError: if the read fails or returns fewer bytes than requested.
```

### Typed shortcuts
Expand Down
54 changes: 54 additions & 0 deletions docs/guide/read-write.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,60 @@ Need the raw bytes with zero interpretation? Use `read_bytes(address, length)`
and `write_bytes(address, data)`.
```

## Reusing a buffer (zero-copy reads)

Every `read_process_memory` call **allocates a fresh Python object** for the
result. That's fine for one-off reads, but in a tight loop that reads the same
region thousands of times — a recorder, a live overlay, a poller — those
throwaway objects pile up and keep the garbage collector busy.

`read_process_memory_into(address, buffer)` reads straight into a buffer **you
own and reuse**, so a long-running loop runs with constant memory instead of a
steady stream of allocations:

```python
buffer = bytearray(16) # allocate once

with OpenProcess(name="game.exe") as process:
while recording:
process.read_process_memory_into(0x7FF40010, buffer)
handle(buffer) # same buffer, refilled in place every loop
```

It fills **`len(buffer)` bytes** (the buffer's size decides how much is read)
and returns the number of bytes read. The bytes land verbatim — no decoding —
so reinterpret them yourself with `int.from_bytes`, `struct.unpack`, a `numpy`
view, and so on.

Any writable, contiguous buffer works — a `bytearray`, a `ctypes` array, a
writable `memoryview`, or a `numpy` array (sized in **bytes**, so a 4-element
`int32` array reads 16 bytes):

```python
import numpy as np

frame = np.zeros(4, dtype=np.int32) # 16 bytes
process.read_process_memory_into(0x7FF40010, frame)
# frame now holds the four int32 values, no per-read allocation
```

### Method signature

```{eval-rst}
.. py:method:: read_process_memory_into(address, buffer)
:no-index:

:param int address: target memory address.
:param buffer: a writable, contiguous buffer-protocol object
(``bytearray``, ``ctypes`` array, writable ``memoryview``, ``numpy``
array, …). Its byte length sets how many bytes are read; the bytes are
written in place with no decoding.
:return: the number of bytes read (the buffer's byte length on success).
:raises TypeError: if ``buffer`` is not a writable buffer (e.g. ``bytes``).
:raises ValueError: if ``buffer`` is empty or not contiguous.
:raises OSError: if the read fails or returns fewer bytes than requested.
```

## Common errors

- **`OSError`** — the address may have been freed between scan and write, or
Expand Down
Loading
Loading