From a686a65dba2cec1c380578ce9fd8012951a8899f Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Thu, 4 Jun 2026 13:41:12 -0300 Subject: [PATCH] feat: typed read/write accessors and forgiving str/bytes writes Add read_int/write_int and the full typed accessor family (signed and unsigned integers, float/double, bool, string and bytes) as thin wrappers over read_process_memory/write_process_memory, with the width and signedness baked into the method name. Available on Windows, Linux and macOS. Make str/bytes writes forgiving: bufflength is now a minimum field width, so the full value is always written (you may count characters, not bytes) and a larger size zero-pads the field. read_string/write_string route through the str path; write_string takes an optional null_terminator. In the GUI app, lock the length field for the String (UTF-8) value type and drive it from the UTF-8 byte length of the typed text. Docs: document the typed accessors and text helpers in the read/write guide, API reference and quickstart, and refresh the README example. --- PyMemoryEditor/app/scanner_panel.py | 55 +++++-- PyMemoryEditor/linux/process.py | 8 +- PyMemoryEditor/macos/process.py | 8 +- PyMemoryEditor/process/abstract.py | 213 +++++++++++++++++++++++++++- PyMemoryEditor/util/__init__.py | 1 + PyMemoryEditor/util/convert.py | 43 ++++++ PyMemoryEditor/win32/process.py | 12 +- README.md | 5 +- docs/api/openprocess.md | 68 +++++++++ docs/guide/read-write.md | 95 ++++++++++++- docs/quickstart.md | 21 +-- docs/why.md | 5 +- tests/test_app_smoke.py | 40 ++++++ tests/test_typed_accessors.py | 160 +++++++++++++++++++++ tests/test_write_str_bytes_width.py | 108 ++++++++++++++ 15 files changed, 800 insertions(+), 42 deletions(-) create mode 100644 tests/test_typed_accessors.py create mode 100644 tests/test_write_str_bytes_width.py diff --git a/PyMemoryEditor/app/scanner_panel.py b/PyMemoryEditor/app/scanner_panel.py index a5f3415..fb06e08 100644 --- a/PyMemoryEditor/app/scanner_panel.py +++ b/PyMemoryEditor/app/scanner_panel.py @@ -119,6 +119,9 @@ def _build_ui(self) -> None: self._value_edit = QLineEdit() self._value_edit.setPlaceholderText("e.g. 100 or 0x64 or Hello") self._value_edit.returnPressed.connect(self._on_value_submitted) + # For String (UTF-8) the length is dictated by the typed text, so keep + # the (disabled) length field in sync as the user types. + self._value_edit.textChanged.connect(self._on_value_text_changed) value_form.addRow("Value:", self._value_edit) self._second_value_edit = QLineEdit() @@ -249,13 +252,20 @@ def _on_type_changed(self, label: str) -> None: return is_pattern = spec.is_pattern + is_string = spec.pytype is str and not is_pattern # AOB pattern mode reuses the "Value" line for the pattern string and # hides / forces the rest of the value-shape controls (length, # second value, scan-type combo) because none of them apply to # pattern matching. + # + # String (UTF-8) also locks the length field: the buffer width is the + # UTF-8 byte length of the typed text (multi-byte aware), so letting the + # user override it would only allow truncating or over-allocating the + # value they entered. The field stays visible as a read-only readout + # kept in sync by _sync_string_length / _on_value_text_changed. self._length_spin.setEnabled( - spec.accepts_length_override and not is_pattern + spec.accepts_length_override and not is_pattern and not is_string ) if is_pattern: @@ -267,16 +277,21 @@ def _on_type_changed(self, label: str) -> None: if is_pattern: # No meaningful length for an AOB pattern; the scanner derives it. + self._length_spin.setMaximum(1024) self._length_spin.setValue(1) self._length_spin.setSuffix(" bytes") - elif spec.accepts_length_override: - if spec.pytype is bytes: - self._length_spin.setValue(max(4, self._length_spin.value())) - self._length_spin.setSuffix(" bytes") - else: - self._length_spin.setValue(16) - self._length_spin.setSuffix(" chars") + elif is_string: + # Length tracks the typed text — raise the ceiling so long strings + # aren't visually clamped, then mirror the current text's byte size. + self._length_spin.setMaximum(2_147_483_647) + self._length_spin.setSuffix(" bytes") + self._sync_string_length() + elif spec.accepts_length_override: # Byte Array (Hex) + self._length_spin.setMaximum(1024) + self._length_spin.setValue(max(4, self._length_spin.value())) + self._length_spin.setSuffix(" bytes") else: + self._length_spin.setMaximum(1024) self._length_spin.setValue(spec.length) self._length_spin.setSuffix(" bytes") @@ -302,6 +317,23 @@ def _on_type_changed(self, label: str) -> None: # let _refresh_buttons re-evaluate now that the type has flipped. self._refresh_buttons() + def _on_value_text_changed(self, text: str) -> None: + # Only String (UTF-8) derives its length from the value text; every + # other type owns its length field independently. + spec = find_spec(self._type_combo.currentText()) + if spec is not None and spec.pytype is str and not spec.is_pattern: + self._sync_string_length(text) + + def _sync_string_length(self, text: Optional[str] = None) -> None: + """Mirror the UTF-8 byte length of the value text into the length field. + + Matches ``parse_value``'s str rule (byte length, not character count) + so the read-only readout shows exactly the buffer width the scan uses. + """ + if text is None: + text = self._value_edit.text() + self._length_spin.setValue(max(1, len(text.encode("utf-8")))) + def _on_scan_type_changed(self, index: int) -> None: _, scan_type = SCAN_TYPE_CHOICES[index] ranged = scan_type in ( @@ -354,8 +386,13 @@ def _build_request(self, *, with_value: bool = True) -> Optional[ScanRequest]: writeable_only=self._writable_check.isChecked(), ) + # String (UTF-8) ignores the (disabled) length field: pass None so + # parse_value derives the buffer width from the typed text's UTF-8 + # byte length. Byte Array still honours the user-set override. length_override = ( - self._length_spin.value() if spec.accepts_length_override else None + self._length_spin.value() + if spec.accepts_length_override and spec.pytype is not str + else None ) # Increased/Decreased/Changed/Unchanged compare current vs previous and diff --git a/PyMemoryEditor/linux/process.py b/PyMemoryEditor/linux/process.py index f85e0da..1e38be5 100644 --- a/PyMemoryEditor/linux/process.py +++ b/PyMemoryEditor/linux/process.py @@ -6,7 +6,7 @@ from ..enums import ScanTypesEnum from ..process import AbstractProcess from ..process.errors import ClosedProcess -from ..util import resolve_bufflength +from ..util import prepare_write, resolve_bufflength from ..process.module_info import ModuleInfo from ..process.region import MemoryRegion from ..process.thread_info import ThreadInfo @@ -210,9 +210,9 @@ def write_process_memory( value: Union[bool, int, float, str, bytes], ) -> Union[bool, int, float, str, bytes]: self.__require_open() - return write_process_memory( - self.pid, address, pytype, resolve_bufflength(pytype, bufflength), value - ) + w_pytype, w_length, w_value = prepare_write(pytype, bufflength, value) + write_process_memory(self.pid, address, w_pytype, w_length, w_value) + return value def allocate_memory(self, size: int, *, permission=None) -> int: self.__require_open() diff --git a/PyMemoryEditor/macos/process.py b/PyMemoryEditor/macos/process.py index 390d758..666f45c 100644 --- a/PyMemoryEditor/macos/process.py +++ b/PyMemoryEditor/macos/process.py @@ -9,7 +9,7 @@ from ..process.module_info import ModuleInfo from ..process.region import MemoryRegion from ..process.thread_info import ThreadInfo -from ..util import resolve_bufflength +from ..util import prepare_write, resolve_bufflength from .functions import ( allocate_memory, @@ -309,9 +309,9 @@ def write_process_memory( :param value: value to be written. """ self.__require_open() - return write_process_memory( - self.__task, address, pytype, resolve_bufflength(pytype, bufflength), value - ) + w_pytype, w_length, w_value = prepare_write(pytype, bufflength, value) + write_process_memory(self.__task, address, w_pytype, w_length, w_value) + return value def allocate_memory(self, size: int, *, permission=None) -> int: self.__require_open() diff --git a/PyMemoryEditor/process/abstract.py b/PyMemoryEditor/process/abstract.py index 4ea1c3f..a3ec5bb 100644 --- a/PyMemoryEditor/process/abstract.py +++ b/PyMemoryEditor/process/abstract.py @@ -356,13 +356,220 @@ def write_process_memory( :param address: target memory address (ex: 0x006A9EC0). :param pytype: type of value to be written into memory (bool, int, float, str or bytes). - :param bufflength: value size in bytes. For numeric types (int, float, - bool) you may pass None to use the default — int→4, float→8, bool→1. - str and bytes require an explicit size. + :param bufflength: value size in bytes. + + * For numeric types (int, float, bool) it is the exact write width; + pass ``None`` to use the default — int→4, float→8, bool→1. + * For ``str`` / ``bytes`` it is a *minimum* field width, not a hard + cap. The whole value is always written: if its encoded form is + longer than ``bufflength`` every byte is still written (so + ``write(addr, str, 3, "olá")`` writes all 4 UTF-8 bytes instead + of raising — you may count characters, not bytes). If it is + shorter, the field is NUL-padded up to ``bufflength`` (handy to + clear a fixed-size buffer). ``None`` writes exactly the encoded + length. ``str`` is encoded as UTF-8; no NUL terminator is + appended. :param value: value to be written. + :return: the original ``value`` passed in. """ raise NotImplementedError() + # ------------------------------------------------------------------ # + # Typed convenience read/write helpers + # ------------------------------------------------------------------ # + # + # Thin, self-documenting wrappers over ``read_process_memory`` / + # ``write_process_memory`` with the type and byte width baked into the + # method name — ``read_int(addr)`` instead of + # ``read_process_memory(addr, int, 4)``. They add no new capability (the + # generic methods already cover every case) but spell out the exact C + # type the caller wants, so the IDE can offer them by name and beginners + # don't have to remember which width and signedness a type uses. + # + # Widths are FIXED and identical on every platform and target bitness — + # ``long``/``ulong`` are always 4 bytes here (matching Win32 ``LONG``), + # ``longlong``/``ulonglong`` always 8 — so the same call reads the same + # number of bytes regardless of OS. For a platform-/target-dependent + # pointer width use :attr:`pointer_size` with the generic methods. + # + # ``int`` decodes as *signed* (see ``util.convert.get_c_type_of``), so the + # signed family delegates straight to it. The unsigned family reads/writes + # raw bytes and reinterprets them with ``signed=False`` in the target's + # native byte order (``sys.byteorder`` — the convention the rest of the + # library follows, e.g. ``resolve_pointer_chain``). + + def _read_unsigned(self, address: int, size: int) -> int: + raw = self.read_process_memory(address, bytes, size) + return int.from_bytes(raw, sys.byteorder, signed=False) + + def _write_unsigned(self, address: int, size: int, value: int) -> int: + raw = int(value).to_bytes(size, sys.byteorder, signed=False) + self.write_process_memory(address, bytes, size, raw) + return value + + # --- signed integers ---------------------------------------------- # + + def read_char(self, address: int) -> int: + """Read a signed 8-bit integer (1 byte). See :meth:`read_uchar` for unsigned.""" + return self.read_process_memory(address, int, 1) + + def read_short(self, address: int) -> int: + """Read a signed 16-bit integer (2 bytes).""" + return self.read_process_memory(address, int, 2) + + def read_int(self, address: int) -> int: + """Read a signed 32-bit integer (4 bytes).""" + return self.read_process_memory(address, int, 4) + + def read_long(self, address: int) -> int: + """Read a signed 32-bit integer (4 bytes, matching Win32 ``LONG``).""" + return self.read_process_memory(address, int, 4) + + def read_longlong(self, address: int) -> int: + """Read a signed 64-bit integer (8 bytes).""" + return self.read_process_memory(address, int, 8) + + def write_char(self, address: int, value: int) -> int: + """Write a signed 8-bit integer (1 byte). Returns ``value``.""" + self.write_process_memory(address, int, 1, value) + return value + + def write_short(self, address: int, value: int) -> int: + """Write a signed 16-bit integer (2 bytes). Returns ``value``.""" + self.write_process_memory(address, int, 2, value) + return value + + def write_int(self, address: int, value: int) -> int: + """Write a signed 32-bit integer (4 bytes). Returns ``value``.""" + self.write_process_memory(address, int, 4, value) + return value + + def write_long(self, address: int, value: int) -> int: + """Write a signed 32-bit integer (4 bytes, Win32 ``LONG``). Returns ``value``.""" + self.write_process_memory(address, int, 4, value) + return value + + def write_longlong(self, address: int, value: int) -> int: + """Write a signed 64-bit integer (8 bytes). Returns ``value``.""" + self.write_process_memory(address, int, 8, value) + return value + + # --- unsigned integers -------------------------------------------- # + + def read_uchar(self, address: int) -> int: + """Read an unsigned 8-bit integer (1 byte, 0..255).""" + return self._read_unsigned(address, 1) + + def read_ushort(self, address: int) -> int: + """Read an unsigned 16-bit integer (2 bytes).""" + return self._read_unsigned(address, 2) + + def read_uint(self, address: int) -> int: + """Read an unsigned 32-bit integer (4 bytes).""" + return self._read_unsigned(address, 4) + + def read_ulong(self, address: int) -> int: + """Read an unsigned 32-bit integer (4 bytes, matching Win32 ``ULONG``).""" + return self._read_unsigned(address, 4) + + def read_ulonglong(self, address: int) -> int: + """Read an unsigned 64-bit integer (8 bytes).""" + return self._read_unsigned(address, 8) + + def write_uchar(self, address: int, value: int) -> int: + """Write an unsigned 8-bit integer (1 byte). Returns ``value``.""" + return self._write_unsigned(address, 1, value) + + def write_ushort(self, address: int, value: int) -> int: + """Write an unsigned 16-bit integer (2 bytes). Returns ``value``.""" + return self._write_unsigned(address, 2, value) + + def write_uint(self, address: int, value: int) -> int: + """Write an unsigned 32-bit integer (4 bytes). Returns ``value``.""" + return self._write_unsigned(address, 4, value) + + def write_ulong(self, address: int, value: int) -> int: + """Write an unsigned 32-bit integer (4 bytes, Win32 ``ULONG``). Returns ``value``.""" + return self._write_unsigned(address, 4, value) + + def write_ulonglong(self, address: int, value: int) -> int: + """Write an unsigned 64-bit integer (8 bytes). Returns ``value``.""" + return self._write_unsigned(address, 8, value) + + # --- floating point ----------------------------------------------- # + + def read_float(self, address: int) -> float: + """Read a 32-bit IEEE-754 float (4 bytes).""" + return self.read_process_memory(address, float, 4) + + def read_double(self, address: int) -> float: + """Read a 64-bit IEEE-754 double (8 bytes).""" + return self.read_process_memory(address, float, 8) + + def write_float(self, address: int, value: float) -> float: + """Write a 32-bit IEEE-754 float (4 bytes). Returns ``value``.""" + self.write_process_memory(address, float, 4, value) + return value + + def write_double(self, address: int, value: float) -> float: + """Write a 64-bit IEEE-754 double (8 bytes). Returns ``value``.""" + self.write_process_memory(address, float, 8, value) + return value + + # --- boolean ------------------------------------------------------- # + + def read_bool(self, address: int) -> bool: + """Read a boolean (1 byte).""" + return self.read_process_memory(address, bool, 1) + + def write_bool(self, address: int, value: bool) -> bool: + """Write a boolean (1 byte). Returns ``value``.""" + self.write_process_memory(address, bool, 1, value) + return value + + # --- strings & raw bytes ------------------------------------------ # + + def read_string(self, address: int, byte_count: int) -> str: + """ + Read up to ``byte_count`` bytes, decode them as UTF-8 and return the + text up to the first NUL terminator (C-string semantics). + + Goes through the ``str`` read path, so invalid UTF-8 becomes ``U+FFFD`` + (``errors="replace"``). ``byte_count`` is the maximum field width to + read; the NUL terminator and everything after it are dropped. To make a + shorter :meth:`write_string` read back cleanly here, write it with + ``null_terminator=True`` (or into an already-zeroed field). + """ + return self.read_process_memory(address, str, byte_count).split("\x00", 1)[0] + + def write_string( + self, address: int, text: str, *, null_terminator: bool = False + ) -> str: + """ + Write ``text`` as a UTF-8 string, writing exactly its bytes and nothing + else. Pass ``null_terminator=True`` to also append a trailing ``\\x00`` + — useful when overwriting a longer string in place so :meth:`read_string` + stops where you intend rather than reading the stale tail. + + Multi-byte characters are handled correctly — the field grows to the + encoded byte length, so you never have to count bytes yourself. + Returns ``text``. For a fixed-width / NUL-padded field, call + :meth:`write_process_memory` with ``pytype=str`` and an explicit + ``bufflength`` instead. + """ + payload = text + "\x00" if null_terminator else text + self.write_process_memory(address, str, None, payload) + return text + + def read_bytes(self, address: int, length: int) -> bytes: + """Read ``length`` raw bytes verbatim (no decoding).""" + return self.read_process_memory(address, bytes, length) + + def write_bytes(self, address: int, data: bytes) -> bytes: + """Write the raw byte string ``data`` verbatim. Returns ``data``.""" + self.write_process_memory(address, bytes, len(data), data) + return data + @abstractmethod def allocate_memory(self, size: int, *, permission=None) -> int: """ diff --git a/PyMemoryEditor/util/__init__.py b/PyMemoryEditor/util/__init__.py index 12d3af1..03bd953 100644 --- a/PyMemoryEditor/util/__init__.py +++ b/PyMemoryEditor/util/__init__.py @@ -4,6 +4,7 @@ _validate_pytype, convert_from_byte_array, get_c_type_of, + prepare_write, resolve_bufflength, value_to_bytes, values_to_bytes, diff --git a/PyMemoryEditor/util/convert.py b/PyMemoryEditor/util/convert.py index 566b047..ae4e665 100644 --- a/PyMemoryEditor/util/convert.py +++ b/PyMemoryEditor/util/convert.py @@ -49,6 +49,49 @@ def resolve_bufflength(pytype: Type, bufflength: Optional[int]) -> int: ) +def prepare_write( + pytype: Type, bufflength: Optional[int], value +) -> Tuple[Type, int, Any]: + """ + Normalize a write request into the ``(pytype, length, value)`` triple a + backend ``WriteProcessMemory`` can hand straight to the OS. + + * **Numeric / bool** — returned unchanged, with ``bufflength`` resolved to + its default (see :func:`resolve_bufflength`). The backend encodes the + value via :func:`get_c_type_of` exactly as before. + + * **str / bytes** — the value is encoded to raw bytes (UTF-8 for ``str``) + and routed through the ``bytes`` path. Here ``bufflength`` is a + *minimum* field width, not a hard cap: + + - the **whole** value is always written, even when its encoded form is + longer than ``bufflength`` — so ``write(addr, str, 3, "olá")`` writes + all 4 UTF-8 bytes instead of raising ``ValueError`` because the caller + counted characters, not bytes; + - when the encoded form is **shorter** than ``bufflength`` the buffer is + NUL-padded up to it, which lets you clear a fixed-size field + (``write(addr, str, 16, "AB")`` writes ``b"AB"`` + 14 zero bytes); + - ``bufflength=None`` writes exactly the encoded length. + + The caller is expected to return its *original* ``value`` to the user, so + this routing through ``bytes`` stays invisible at the public API. + """ + _validate_pytype(pytype) + + if pytype is str or pytype is bytes: + raw = value.encode("utf-8") if isinstance(value, str) else value + if not isinstance(raw, (bytes, bytearray)): + raise TypeError( + "value must be str or bytes when pytype is str/bytes, got %s." + % type(value).__name__ + ) + raw = bytes(raw) + width = len(raw) if bufflength is None else max(bufflength, len(raw)) + return bytes, width, raw.ljust(width, b"\x00") + + return pytype, resolve_bufflength(pytype, bufflength), value + + def convert_from_byte_array( byte_array: ctypes.Array, pytype: Type[T], length: int ) -> T: diff --git a/PyMemoryEditor/win32/process.py b/PyMemoryEditor/win32/process.py index 5ee8de4..87595da 100644 --- a/PyMemoryEditor/win32/process.py +++ b/PyMemoryEditor/win32/process.py @@ -3,7 +3,7 @@ import ctypes from typing import Dict, Generator, Optional, Sequence, Tuple, Type, TypeVar, Union -from ..util import resolve_bufflength +from ..util import prepare_write, resolve_bufflength from ..enums import ScanTypesEnum from ..process import AbstractProcess @@ -313,13 +313,15 @@ def write_process_memory( ) -> Union[bool, int, float, str, bytes]: self.__require_open() self.__require_write() - return WriteProcessMemory( + w_pytype, w_length, w_value = prepare_write(pytype, bufflength, value) + WriteProcessMemory( self.__process_handle, address, - pytype, - resolve_bufflength(pytype, bufflength), - value, + w_pytype, + w_length, + w_value, ) + return value def allocate_memory(self, size: int, *, permission=None) -> int: self.__require_open() diff --git a/README.md b/README.md index 9103cbf..0ef2d43 100644 --- a/README.md +++ b/README.md @@ -69,8 +69,9 @@ with OpenProcess(process_name="game.exe") as process: for address in process.search_by_value(int, 4, 100): print(f"Found at 0x{address:X}") - # Write a new value at a known address. - process.write_process_memory(0x006A9EC0, int, 4, 9999) + # Read the current value, then write a new one back. + current = process.read_int(address) + process.write_int(address, current + 500) ``` That's it — read, write or scan another process in three lines, the same way on every platform. diff --git a/docs/api/openprocess.md b/docs/api/openprocess.md index 806f575..abc7baa 100644 --- a/docs/api/openprocess.md +++ b/docs/api/openprocess.md @@ -110,10 +110,78 @@ with OpenProcess( :param int address: target memory address. :param Type pytype: one of the five supported types. :param int bufflength: value size in bytes (``None`` for numeric defaults). + For ``str`` / ``bytes`` it is a *minimum* width — the whole value is + always written, and a larger size zero-pads the field. :param value: the value to write. :returns: the written value. ``` +### Typed shortcuts + +Convenience `read_*` / `write_*` pairs with the size and signedness baked into +the name — see :doc:`../guide/read-write` for examples. Widths are fixed and +identical on every platform. + +```{eval-rst} +.. py:method:: read_char(address) + :no-index: + + Read / write a signed 8-bit integer (1 byte). Pair: ``write_char(address, value)``. + +.. py:method:: read_short(address) + :no-index: + + Signed 16-bit integer (2 bytes). Pair: ``write_short``. + +.. py:method:: read_int(address) + :no-index: + + Signed 32-bit integer (4 bytes). Pair: ``write_int``. + +.. py:method:: read_long(address) + :no-index: + + Signed 32-bit integer (4 bytes, Win32 ``LONG``). Pair: ``write_long``. + +.. py:method:: read_longlong(address) + :no-index: + + Signed 64-bit integer (8 bytes). Pair: ``write_longlong``. + +.. py:method:: read_uchar(address) + :no-index: + + Unsigned variants of the above: ``read_uchar`` / ``read_ushort`` / + ``read_uint`` / ``read_ulong`` / ``read_ulonglong`` (1 / 2 / 4 / 4 / 8 bytes), + each with a matching ``write_*``. + +.. py:method:: read_float(address) + :no-index: + + 32-bit float (4 bytes). Pair: ``write_float``. + +.. py:method:: read_double(address) + :no-index: + + 64-bit double (8 bytes). Pair: ``write_double``. + +.. py:method:: read_bool(address) + :no-index: + + Boolean (1 byte). Pair: ``write_bool``. + +.. py:method:: read_string(address, byte_count) + :no-index: + + Read up to ``byte_count`` bytes, decode UTF-8, return the text up to the + first NUL. Pair: ``write_string(address, text, *, null_terminator=False)``. + +.. py:method:: read_bytes(address, length) + :no-index: + + Read ``length`` raw bytes. Pair: ``write_bytes(address, data)``. +``` + ### Searching ```{eval-rst} diff --git a/docs/guide/read-write.md b/docs/guide/read-write.md index a8a1520..6219a9e 100644 --- a/docs/guide/read-write.md +++ b/docs/guide/read-write.md @@ -19,7 +19,15 @@ process memory: For numeric types, you can pass `bufflength=None` (or just omit it) to use the -default. For `str` and `bytes`, you **must** pass the size. +default. For `str` and `bytes`, the size is **required when reading** (the +library needs to know how many bytes to pull back) but **optional when +writing** — a write simply stores the value you gave it. + +```{tip} +Prefer the **typed shortcuts** below (`read_int`, `write_float`, `read_string`…) +if you don't want to think about sizes at all — the width is baked into the +method name. +``` ## Reading a value @@ -78,13 +86,23 @@ with OpenProcess(process_name="notepad.exe") as process: # Write a 2-byte int explicitly process.write_process_memory(address, int, 2, 42) - # Write a string - process.write_process_memory(address, str, 32, "Hello!") + # Write a string — the size is optional; None just stores your text as-is + process.write_process_memory(address, str, None, "Hello!") # Write raw bytes process.write_process_memory(address, bytes, 4, b"\xDE\xAD\xBE\xEF") ``` +```{admonition} Writing text? Count characters, not bytes. +:class: tip + +For `str` / `bytes` writes, `bufflength` is just a **minimum** width — your +value is always written in full. So `write_process_memory(addr, str, 3, "olá")` +writes all of `"olá"` even though the `á` takes 2 bytes (4 bytes total): you can +think in characters and never worry about UTF-8 byte math. Pass a *larger* +size to clear a fixed-size field (the extra space is zero-filled). +``` + ### Method signature ```{eval-rst} @@ -99,13 +117,82 @@ with OpenProcess(process_name="notepad.exe") as process: :return: the written value. ``` +## Typed shortcuts + +Don't want to remember that an *Int32* is 4 bytes or that *unsigned* needs +special handling? Use the **typed shortcuts**. Each one is a `read_*` / +`write_*` pair with the size and signedness baked into the name: + +```python +with OpenProcess(process_name="game.exe") as process: + hp = process.read_int(0x7FF40010) # signed, 4 bytes + gold = process.read_uint(0x7FF40014) # unsigned, 4 bytes + speed = process.read_float(0x7FF40018) # 32-bit float + + process.write_int(0x7FF40010, hp + 100) # heal up + process.write_bool(0x7FF4001C, True) # toggle a flag +``` + +No `bufflength`, no `pytype` — just the address (and the value, when writing). +Here's the full set; every `read_*` has a matching `write_*`: + + + + + + + + + + + + +
ShortcutReads / writesBytes
read_char / read_uchar8-bit integer — signed / unsigned1
read_short / read_ushort16-bit integer — signed / unsigned2
read_int / read_uint32-bit integer — signed / unsigned4
read_long / read_ulong32-bit integer — signed / unsigned4
read_longlong / read_ulonglong64-bit integer — signed / unsigned8
read_float32-bit floating point4
read_double64-bit floating point8
read_boolboolean1
read_string / read_bytestext / raw bytesyou choose
+ +```{note} +Widths are **fixed and the same on every OS** — `long` is always 4 bytes here, +`longlong` always 8 — so your code reads the same number of bytes on Windows, +Linux and macOS. +``` + +## Working with text + +`read_string` and `write_string` are the friendly way to handle text — no byte +counting, no manual decoding: + +```python +with OpenProcess(process_name="game.exe") as process: + # Write your text — UTF-8 encoding (accents, emoji…) is handled for you. + process.write_string(0x7FF40020, "Pedro") + + # Read it back: read up to 32 bytes, stop at the first NUL terminator. + name = process.read_string(0x7FF40020, 32) # -> "Pedro" +``` + +`read_string` reads up to the size you pass and returns everything **before the +first `\0`**, so a generous size like `32` gives you the real string without the +trailing padding. `write_string` writes exactly your text — pass +`null_terminator=True` if you're overwriting a longer value and want a clean +cut-off: + +```python +process.write_string(0x7FF40020, "Ann", null_terminator=True) +# read_string now stops right after "Ann", even if "Pedro" was there before. +``` + +```{seealso} +Need the raw bytes with zero interpretation? Use `read_bytes(address, length)` +and `write_bytes(address, data)`. +``` + ## Common errors - **`OSError`** — the address may have been freed between scan and write, or the page might not be writable. Wrap one-off writes in `try/except OSError`. - **`PermissionError`** — the handle was opened without write access (Windows read-only handle). See [Opening a process](opening-process.md#permissions-windows-only). -- **`ValueError`** — `bufflength` was omitted for a `str` or `bytes` write. +- **`ValueError`** — `bufflength` was omitted for a `str` or `bytes` **read** + (a write doesn't need it — it sizes itself to your value). ## Reading many addresses efficiently diff --git a/docs/quickstart.md b/docs/quickstart.md index 4bbc424..67bf4bf 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -37,8 +37,8 @@ flag needed for the common case. ## 3. Read and write a value -The building blocks are `read_process_memory` and `write_process_memory`. -For numeric types (`int`, `float`, `bool`) the size is inferred. +The easiest way is the **typed shortcuts** — the size is baked into the method +name, so there's nothing to remember: ```python from PyMemoryEditor import OpenProcess @@ -46,20 +46,23 @@ from PyMemoryEditor import OpenProcess with OpenProcess(process_name="notepad.exe") as process: address = 0x0005000C - # Read 4 bytes as an int - value = process.read_process_memory(address, int) + value = process.read_int(address) # read a 4-byte int print("Current:", value) - # Write a new value (pass None to use the default size) - process.write_process_memory(address, int, None, value + 7) + process.write_int(address, value + 7) # write it back ``` -Strings and raw bytes need an explicit size: +There's a `read_*` / `write_*` pair for every common type — `read_float`, +`read_bool`, `read_uint`, `read_string`, and more: ```python -name = process.read_process_memory(address, str, 32) +name = process.read_string(address, 32) # up to 32 bytes, decoded as text ``` +Prefer to spell out the type yourself? The generic `read_process_memory` / +`write_process_memory` cover every case too — see +[Reading and writing memory](guide/read-write.md). + ## 4. Run your first scan You rarely know the address of a value up front — you **find it by scanning**. @@ -100,7 +103,7 @@ with OpenProcess(process_name="game.exe") as process: # 4. Overwrite the survivors back to a high value. for address in survivors: - process.write_process_memory(address, int, 4, 9999) + process.write_int(address, 9999) ``` For big targets, see [the refine-scan workflow](guide/searching.md#the-refine-scan-workflow) diff --git a/docs/why.md b/docs/why.md index 0be663c..a2b13f4 100644 --- a/docs/why.md +++ b/docs/why.md @@ -100,8 +100,9 @@ with OpenProcess(process_name="game.exe") as process: for address in process.search_by_value(int, 4, 100): print(f"Found at 0x{address:X}") - # Write a new value at a known address. - process.write_process_memory(address, int, 4, 9999) + # Read the current value, then write a new one back. + current = process.read_int(address) + process.write_int(address, current + 500) ``` Convinced? Head to the [Installation](installation.md) page, then the diff --git a/tests/test_app_smoke.py b/tests/test_app_smoke.py index 85c0d18..d8b6053 100644 --- a/tests/test_app_smoke.py +++ b/tests/test_app_smoke.py @@ -95,6 +95,46 @@ def test_qapplication_starts_under_offscreen(qtbot): assert app is not None +@pytest.mark.skipif(not qtbot_available, reason="pytest-qt not installed.") +def test_string_type_locks_length_to_value_text(qtbot): + """ + Selecting "String (UTF-8)" disables the length field and drives it from the + UTF-8 byte length of the typed value, so the buffer width always matches the + text the user entered (multi-byte aware). Other types keep an editable length. + """ + from PySide6.QtWidgets import QApplication + + from PyMemoryEditor.app.scanner_panel import ScannerPanel + + QApplication.instance() or QApplication([]) + panel = ScannerPanel() + qtbot.addWidget(panel) + + # Byte Array exposes an editable length field (user-set buffer width). + panel._type_combo.setCurrentText("Byte Array (Hex)") + assert panel._length_spin.isEnabled() + + # Switching to String locks the length field... + panel._type_combo.setCurrentText("String (UTF-8)") + assert not panel._length_spin.isEnabled() + + # ...and the length tracks the UTF-8 byte size of the value text. "olá" is + # 3 characters but 4 bytes (the 'á' is two bytes). + panel._value_edit.setText("olá") + assert panel._length_spin.value() == 4 + + request = panel._build_request() + assert request is not None + assert request.value == "olá" + assert request.length == 4 # derived from the text, not the spin override + + # Switching back to Byte Array re-enables the field. + panel._type_combo.setCurrentText("Byte Array (Hex)") + assert panel._length_spin.isEnabled() + + panel.close() + + @pytest.mark.skipif(not qtbot_available, reason="pytest-qt not installed.") def test_pointer_scan_dialog_constructs_and_prefills(qtbot): """ diff --git a/tests/test_typed_accessors.py b/tests/test_typed_accessors.py new file mode 100644 index 0000000..0d10a99 --- /dev/null +++ b/tests/test_typed_accessors.py @@ -0,0 +1,160 @@ +# -*- coding: utf-8 -*- + +""" +Tests for the typed convenience read/write helpers on ``AbstractProcess`` +(``read_int``, ``write_float``, ``read_ulonglong``, ``read_string`` ...). + +They are thin wrappers over ``read_process_memory`` / ``write_process_memory``, +so the goal here is to confirm each one targets the right byte width and +signedness. We plant ctypes values on the test's own heap and read them back +through the typed methods, and round-trip writes through the matching reads. +""" + +import ctypes +import os +import sys + +import pytest + +if sys.platform not in ("win32", "darwin") and not sys.platform.startswith("linux"): + pytest.skip("Platform not supported by PyMemoryEditor", allow_module_level=True) + + +from PyMemoryEditor import OpenProcess # noqa: E402 + + +@pytest.fixture +def process(): + handle = OpenProcess(pid=os.getpid()) + try: + yield handle + finally: + handle.close() + + +# (method name, ctypes type, planted value) — signed integer family. +SIGNED_INT_CASES = [ + ("read_char", ctypes.c_int8, -7), + ("read_short", ctypes.c_int16, -1234), + ("read_int", ctypes.c_int32, -123456), + ("read_long", ctypes.c_int32, -123456), + ("read_longlong", ctypes.c_int64, -1234567890123), +] + +# Unsigned integer family — values with the top bit set so a signed misread +# would surface as a negative number. +UNSIGNED_INT_CASES = [ + ("read_uchar", ctypes.c_uint8, 0xFF), + ("read_ushort", ctypes.c_uint16, 0xFFFE), + ("read_uint", ctypes.c_uint32, 0xFFFFFFFE), + ("read_ulong", ctypes.c_uint32, 0xFFFFFFFE), + ("read_ulonglong", ctypes.c_uint64, 0xFFFFFFFFFFFFFFFE), +] + + +@pytest.mark.parametrize("method, ctype, value", SIGNED_INT_CASES) +def test_signed_int_readers(process, method, ctype, value): + holder = ctype(value) + result = getattr(process, method)(ctypes.addressof(holder)) + assert result == value + + +@pytest.mark.parametrize("method, ctype, value", UNSIGNED_INT_CASES) +def test_unsigned_int_readers(process, method, ctype, value): + holder = ctype(value) + result = getattr(process, method)(ctypes.addressof(holder)) + assert result == value + assert result > 0 # must never sign-extend into a negative + + +def test_unsigned_reader_width_isolation(process): + """An unsigned read must not bleed bytes from neighbouring memory.""" + buffer = (ctypes.c_uint8 * 8)(0xFE, 0x00, 0x00, 0x00, 0xAA, 0xBB, 0xCC, 0xDD) + # read_uchar at offset 0 must see only the 0xFE byte, not the trailing junk. + assert process.read_uchar(ctypes.addressof(buffer)) == 0xFE + + +def test_float_and_double(process): + f = ctypes.c_float(3.5) + d = ctypes.c_double(2.718281828) + assert process.read_float(ctypes.addressof(f)) == pytest.approx(3.5) + assert process.read_double(ctypes.addressof(d)) == pytest.approx(2.718281828) + + +def test_bool(process): + t = ctypes.c_bool(True) + f = ctypes.c_bool(False) + assert process.read_bool(ctypes.addressof(t)) is True + assert process.read_bool(ctypes.addressof(f)) is False + + +def test_read_string_stops_at_nul(process): + buffer = ctypes.create_string_buffer(b"hello\x00leftover", 32) + assert process.read_string(ctypes.addressof(buffer), 32) == "hello" + + +def test_read_bytes_verbatim(process): + buffer = (ctypes.c_uint8 * 4)(0xDE, 0xAD, 0xBE, 0xEF) + assert process.read_bytes(ctypes.addressof(buffer), 4) == b"\xde\xad\xbe\xef" + + +# --- write round-trips -------------------------------------------------- # + + +def test_write_signed_round_trip(process): + holder = ctypes.c_int32(0) + assert process.write_int(ctypes.addressof(holder), -98765) == -98765 + assert process.read_int(ctypes.addressof(holder)) == -98765 + + +def test_write_unsigned_round_trip(process): + holder = ctypes.c_uint64(0) + big = 0xFFFFFFFFFFFFFFFE + assert process.write_ulonglong(ctypes.addressof(holder), big) == big + assert process.read_ulonglong(ctypes.addressof(holder)) == big + + +def test_write_float_round_trip(process): + holder = ctypes.c_float(0.0) + process.write_float(ctypes.addressof(holder), 1.25) + assert process.read_float(ctypes.addressof(holder)) == pytest.approx(1.25) + + +def test_write_bool_round_trip(process): + holder = ctypes.c_bool(False) + process.write_bool(ctypes.addressof(holder), True) + assert process.read_bool(ctypes.addressof(holder)) is True + + +def test_write_string_round_trip(process): + buffer = ctypes.create_string_buffer(32) + assert process.write_string(ctypes.addressof(buffer), "héllo") == "héllo" + assert process.read_string(ctypes.addressof(buffer), 32) == "héllo" + + +def test_write_string_default_no_terminator(process): + """By default write_string writes just the characters — the tail survives.""" + buffer = ctypes.create_string_buffer(b"XXXXXXXX", 16) + process.write_string(ctypes.addressof(buffer), "ab") + # 'ab' overwrote the first two bytes; the rest of the field is untouched. + assert process.read_bytes(ctypes.addressof(buffer), 4) == b"abXX" + + +def test_write_string_with_terminator(process): + """null_terminator=True NUL-terminates so the old tail isn't read back.""" + buffer = ctypes.create_string_buffer(b"helloworld", 32) + process.write_string(ctypes.addressof(buffer), "hi", null_terminator=True) + assert process.read_string(ctypes.addressof(buffer), 32) == "hi" + + +def test_write_string_multibyte_does_not_raise(process): + """Counting characters, not bytes, must succeed for write_string too.""" + buffer = ctypes.create_string_buffer(16) + process.write_string(ctypes.addressof(buffer), "ção") + assert process.read_string(ctypes.addressof(buffer), 16) == "ção" + + +def test_write_bytes_round_trip(process): + buffer = (ctypes.c_uint8 * 4)() + process.write_bytes(ctypes.addressof(buffer), b"\x01\x02\x03\x04") + assert process.read_bytes(ctypes.addressof(buffer), 4) == b"\x01\x02\x03\x04" diff --git a/tests/test_write_str_bytes_width.py b/tests/test_write_str_bytes_width.py new file mode 100644 index 0000000..6556e24 --- /dev/null +++ b/tests/test_write_str_bytes_width.py @@ -0,0 +1,108 @@ +# -*- coding: utf-8 -*- + +""" +Tests for the ``str`` / ``bytes`` write-width semantics of +``write_process_memory`` (see ``util.convert.prepare_write``). + +``bufflength`` is a *minimum* field width for these types, not a hard cap: +the whole value is always written (counting characters, not bytes, must not +raise), shorter values NUL-pad up to ``bufflength``, and ``None`` writes +exactly the encoded length. +""" + +import ctypes +import os +import sys + +import pytest + +if sys.platform not in ("win32", "darwin") and not sys.platform.startswith("linux"): + pytest.skip("Platform not supported by PyMemoryEditor", allow_module_level=True) + + +from PyMemoryEditor import OpenProcess # noqa: E402 +from PyMemoryEditor.util import prepare_write # noqa: E402 + + +@pytest.fixture +def process(): + handle = OpenProcess(pid=os.getpid()) + try: + yield handle + finally: + handle.close() + + +# --- prepare_write unit tests (platform-independent) -------------------- # + + +def test_prepare_write_multibyte_grows_to_fit(): + """"olá" is 3 characters but 4 UTF-8 bytes — width must grow, not raise.""" + pytype, length, raw = prepare_write(str, 3, "olá") + assert pytype is bytes + assert length == 4 + assert raw == "olá".encode("utf-8") + + +def test_prepare_write_pads_up_to_bufflength(): + pytype, length, raw = prepare_write(str, 16, "AB") + assert pytype is bytes + assert length == 16 + assert raw == b"AB" + b"\x00" * 14 + + +def test_prepare_write_none_uses_encoded_length(): + pytype, length, raw = prepare_write(str, None, "héllo") + assert pytype is bytes + assert length == 6 # 'é' is two bytes + assert raw == "héllo".encode("utf-8") + + +def test_prepare_write_bytes_grows_and_pads(): + assert prepare_write(bytes, 2, b"\x01\x02\x03\x04") == (bytes, 4, b"\x01\x02\x03\x04") + assert prepare_write(bytes, 4, b"\x01\x02") == (bytes, 4, b"\x01\x02\x00\x00") + assert prepare_write(bytes, None, b"\x01\x02") == (bytes, 2, b"\x01\x02") + + +def test_prepare_write_numeric_unchanged(): + assert prepare_write(int, None, 5) == (int, 4, 5) + assert prepare_write(int, 8, 5) == (int, 8, 5) + assert prepare_write(float, None, 1.0) == (float, 8, 1.0) + assert prepare_write(bool, None, True) == (bool, 1, True) + + +def test_prepare_write_rejects_non_str_bytes_value(): + with pytest.raises(TypeError): + prepare_write(bytes, 4, 1234) + + +# --- end-to-end writes against our own memory --------------------------- # + + +def test_write_multibyte_string_does_not_raise(process): + """The headline case: counting characters must not raise on multibyte.""" + buffer = ctypes.create_string_buffer(8) + # 3 characters, 4 bytes — would have raised ValueError before. + assert process.write_process_memory(ctypes.addressof(buffer), str, 3, "olá") == "olá" + assert process.read_string(ctypes.addressof(buffer), 8) == "olá" + + +def test_write_returns_original_value_not_bytes(process): + """str writes must return the original str, not the routed-through bytes.""" + buffer = ctypes.create_string_buffer(16) + result = process.write_process_memory(ctypes.addressof(buffer), str, 16, "name") + assert result == "name" + assert isinstance(result, str) + + +def test_write_pads_fixed_field(process): + """Writing a short string into a wider field clears the trailing bytes.""" + buffer = (ctypes.c_uint8 * 8)(*([0xFF] * 8)) + process.write_process_memory(ctypes.addressof(buffer), str, 8, "AB") + assert process.read_bytes(ctypes.addressof(buffer), 8) == b"AB" + b"\x00" * 6 + + +def test_write_bytes_round_trip_grows(process): + buffer = (ctypes.c_uint8 * 4)() + process.write_process_memory(ctypes.addressof(buffer), bytes, 2, b"\xde\xad\xbe\xef") + assert process.read_bytes(ctypes.addressof(buffer), 4) == b"\xde\xad\xbe\xef"