Skip to content

Libpince Engine

brkzlr edited this page Jul 26, 2026 · 8 revisions

Libpince Engine is PINCE's in-app scripting environment for inspecting and modifying the attached process. Think of it as PINCE's equivalent of CE's scripting in Auto Assembler, except we use Python instead of Lua.

You can use it for more advanced stuff like automating reads/writes, AOB scans, instruction patching, NOPing, small code injections and quick helper scripts you do not want to repeat by hand.

Why Python instead of Lua?

PINCE is already written in Python, so using Python makes it simpler for everybody. You also get access to the same backend Python functions PINCE itself uses.

Even if we used Lua and emulated CE's script functions so you could directly import CE script files, it probably still would not work well because Linux and Windows memory tools are different enough. See the FAQ entry for CE tables. There is not much point in creating a Lua shim just to hit those problems anyway.

How can I access it?

  • Attach to a process first
  • Open Memory View
  • Go to Tools -> Libpince Engine

Editor features

The script editor has a bunch of conveniences to make life easier:

  • Multiple tabs: click the + tab to create a new script. Tabs with unsaved changes show a *. Each tab has its own namespace, so variables and functions you define in one script do not bleed into another.
  • Autocomplete: triggers automatically as you type or after a .. Press Ctrl+Space to force it. Arrow keys navigate, Enter or Tab accepts, Esc dismisses.
  • Tooltip help: type ( after a function name to see its signature and docstring.
  • Open / Save: Ctrl+O / Ctrl+S or use the File menu.
  • Smart indent: Tab and Shift+Tab work on multi-line selections.
  • Syntax highlighting: keywords, builtins, namespaces, strings including f-strings, comments and numbers.

What can I do here?

You can start by clicking on Templates and checking each one out to get a grasp of the basics.

Once you have written a script, the quickest way to run it is Ctrl+R, which is Run -> Run selection. With nothing selected this runs the whole script. If you highlight a part first, it only runs that part.

Enable and disable sections

A script can be split into two halves with [ENABLE] and [DISABLE] tags, the same way CE's Auto Assembler scripts are.

When both tags are present, anything written before the first tag is a prelude that runs before both halves.

  • Run -> Run [ENABLE] or Ctrl+Shift+E runs the prelude plus the [ENABLE] half.
  • Run -> Run [DISABLE] or Ctrl+Shift+D runs the prelude plus the [DISABLE] half.
  • A script with only [ENABLE] runs all its other code as the enable half and has nothing to disable.
  • A script with only [DISABLE] has an empty enable half and runs all its other code as the disable half.
  • A script with no tags is treated as one big enable, so the whole thing just runs and there is nothing to disable.

The tags are case-insensitive and whitespace around them is ignored, so [enable] is fine too. Splitting a script like this is what lets you toggle it on/off from the cheat table. See Sending scripts to the cheat table below.

How scripts run

  • Each editor tab has its own persistent namespace. Variables and functions you define in one run are still available the next time you run something.
    • This is useful while iterating. You can prepare some state in one run, then fiddle with it via Ctrl+R on a selection. It also means something you grabbed in [ENABLE], like an AOB scan result, is still around when you later run [DISABLE], which is how the templates undo what they did.
  • Stdout, stderr and Python tracebacks are captured and shown in the output pane. print() goes there, not to your terminal.
  • Closing a tab discards its namespace.

Sending scripts to the cheat table

Once a script does what you want, you can push it into PINCE's address table as a toggleable entry, just like flipping a cheat on/off in CE.

Press Run -> Send to cheat table or use Ctrl+Shift+T. You will get asked for a name which becomes the entry's description, then a new row shows up in the address table with Script as its type.

  • Ticking the row's checkbox runs the [ENABLE] half. Unticking it runs the [DISABLE] half. Single-section and tagless scripts follow the behavior described above.
  • If the [ENABLE] run errors out, the checkbox unticks itself and shows you the error so a broken script does not sit there pretending to be active.
  • The namespace is kept between ticking on/off, so an addr or cave you set up in [ENABLE] is still there for [DISABLE] to clean up.

To change an entry later, double click it or right click it and pick Edit script. It opens back up in the Libpince Engine. Your edits flow straight back into the saved script and renaming the row's description renames the tab too.

Script entries get saved and loaded alongside the rest of your cheat table in .pct files.

What functions do we have available?

You have the typedefs, utils and debugcore namespaces included in the script editor. You can access their variables/functions directly, for example debugcore.nop_instruction(addr, len).

Below you can also find aliases for the common functions you will probably want first.

Reading and writing values

  • read_int(address, size=4, signed=False)
    • Read an integer at an address. Size 1 is int8, 2 is int16, 4 is int32, 8 is int64.
  • write_int(value, address, size=4)
    • Write an integer at an address. Same sizes as above.
  • read_bitfield(address, bits=1, start_bit=0, signed=False) and write_bitfield(value, address, bits=1, start_bit=0, signed=False)
    • bits is the number of bits in the field. start_bit is its position inside the first byte, from 0 through 7.
    • Set signed=True to read or write signed values.
    • Writes only replace the selected field, so other bits sharing the same bytes are left alone.
  • read_float(address, double=False) and write_float(value, address, double=False)
    • Same idea as the int functions. Set double=True for Float64. Otherwise Float32 is used.
  • read_string(address, length=128, encoding="utf8", zero_terminate=True)
    • Read a string with the given max length and encoding.
    • zero_terminate=True means stop at the first null character if one is found. It does not add a null character to the returned string.
    • Other encoding values are ascii, utf16 and utf32.
  • write_string(value, address, encoding="utf8", zero_terminate=True)
    • Write a string to an address.
    • zero_terminate=True appends the null terminator while writing. Turn it off if the target buffer is not meant to be null terminated.
  • read_bytes(address, length) and write_bytes(data, address)
    • Read/write raw bytes, also called AOB/Array of Bytes.

Using ValueTypes directly

The helpers above cover the common cases. If you need the full set of type options, create a ValueType and pass it to debugcore.read_memory() or debugcore.write_memory():

value_type = typedefs.BitFieldValueType(
    bits=5,
    start_bit=2,
    value_repr=VALUE_REPR.SIGNED,
)

value = debugcore.read_memory(address("game.exe+0x1234"), value_type)
debugcore.write_memory(address("game.exe+0x1234"), value_type, -3)

The available types are:

  • IntegerValueType(bits, value_repr=..., endian=...)
  • FloatValueType(bits, endian=...)
  • StringValueType(encoding, length=..., zero_terminate=..., endian=...)
  • ByteArrayValueType(length)
  • BitFieldValueType(bits, start_bit, value_repr=...)

Each ValueType contains everything needed to read, display, parse and write that type. You do not pass separate type options to read_memory() or write_memory().

Address helper

  • address(expression)
    • Resolves anything address-shaped to an int. You can pass:
      • An integer: address(0x401000) -> 0x401000
      • A hex string: address("0x401000") -> 0x401000
      • A symbol name: address("main")
      • A register: address("$rax")
      • A module expression: address("game.exe+0x1234")
      • A GDB expression: address("main+0x42") or address("&player1")
    • Every read/write function above runs its address parameter through this internally, so you can pass any of these forms directly to read_int, write_bytes, patch and similar helpers. You only need to call address() yourself when you want the resolved integer for arithmetic.

Patching code

  • patch(data, address, expected=None)
    • Patch instruction bytes at the target address. data can be bytes, bytearray, list[int] or a hex string like "90 90 90".
    • If expected is provided, the original bytes at address are read first and compared against it. If they do not match, the patch raises and nothing is written. Treat this as your guardrail when patching AOB-scanned addresses so a wrong match cannot silently corrupt code.
    • expected should describe the same number of bytes you are replacing.
    • Can be undone with restore(address).
  • nop(address, length)
    • Replace length bytes starting at address with NOP instructions.
    • Can be undone with restore(address).
  • restore(address)
    • Reverts a previous patch() or nop() at the given address. It restores the bytes PINCE saved the first time that address was patched.

AOB scanning

  • aobscan(pattern, writable=None, executable=None, limit=1000)
    • Scans the attached process's readable memory regions for an array-of-bytes pattern and returns matching addresses in ascending order.
    • pattern is whitespace-separated hex tokens. Use ? or ?? for wildcard bytes: "48 8b 05 ?? ?? ?? ??".
    • writable=True restricts the scan to writable regions, writable=False excludes them, None includes both. Same for executable.
    • Useful filters are executable=True, writable=False for code and executable=False for data.
    • limit caps the number of results. Pass None for unlimited. Zero or a negative value returns no matches.
  • aobscan_first(pattern, writable=None, executable=None)
    • Same as aobscan() but returns just the first matching address or None if nothing matched. Use this when you only want one hit and do not want list indexing.

Memory allocation

  • alloc(size, name=None)
    • Allocates size bytes of writable memory in the attached process and returns its address. Use this for data/buffers, not code caves.
    • If you do not pass a name, an automatic one is generated. Provide one if you want to free it later by name.
  • dealloc(name)
    • Frees memory previously allocated with alloc(size, name).
    • Only call this for allocations made by alloc(). Freeing the wrong thing can crash the process.
  • alloc_cave(size, name=None, near=None)
    • Allocates size bytes of executable memory via mmap and returns its address. Use this for code caves.
    • near asks PINCE to find a free nearby page suitable for relative jumps. PINCE requires the chosen mapping to land at the requested candidate and, on 64-bit targets, keeps it safely inside relative-jump range. The allocation returns 0 if no suitable location can be mapped instead of silently accepting a distant one.
    • If you do not pass a name, an automatic one is generated. Provide one if you want to free it later by name.
  • dealloc_cave(name)
    • Frees a code cave previously allocated with alloc_cave(size, name, near).
    • Only call this for allocations made by alloc_cave(). Do not mix dealloc() and dealloc_cave().

Assembling and disassembling

  • assemble(instructions, address=0)
    • Assemble x86 instructions and return the resulting bytes. Multiple instructions can be separated by ; or newlines. Multi-line strings work well here:
      code = assemble("""
      mov rax, 1
      add rax, 2
      """, cave)
    • address matters for position-dependent instructions like jmp rel32 or call rel32, because their encoded offset is computed from that base address.
    • Architecture, 32-bit or 64-bit, is picked automatically based on the attached process.
  • disassemble_bytes(data, address=0)
    • The inverse: take raw bytes and return instructions separated by ;.
    • Same address rules as assemble() apply. It affects how relative branches and RIP-relative operands are printed.

Modules

  • module_base(name)
    • Returns the module's logical load base, not the start of its first raw mapping.
    • Pass a unique basename like "libc.so.6". If different mapped files have the same basename, pass the full mapped path to select one.
    • Matching is case-insensitive. Returns None if the name is missing or ambiguous.
    • Raises if no process is attached.

Registers

  • regs()
    • Returns a dict of registers and their current values, for example {"rax": ..., "rbx": ...}.
    • Values are returned as strings because they come from GDB/PINCE register output. If you need an integer address for math, address("$rax") is usually easier.
  • reg(name)
    • Returns the value of a single register. The leading $ is optional, so both reg("rax") and reg("$rax") work.
  • set_reg(name, value)
    • Sets the value of a register through GDB. Leading $ is optional, same as reg().

Raw GDB access

  • gdb(command, cli_output=True)
    • Run an arbitrary GDB command and return its output as a string. Use this for anything the alias layer does not cover, for example gdb("info proc mappings") or gdb("bt full").

Things to be careful with

Libpince Engine scripts are Python code running inside PINCE, not inside the game. The helpers then talk to the attached process through PINCE/GDB.

That distinction matters:

  • print() prints to the Libpince Engine output pane, not the game.
  • A normal Python variable lives in PINCE. It is not a variable in the target process.
  • read_*, write_*, patch() and nop() touch the target process memory.
  • gdb("call ..."), alloc(), dealloc(), alloc_cave() and dealloc_cave() can make the target execute code. If the call blocks, crashes or hits a bad state, the game can hang/crash too.
  • If the process is running while a helper needs GDB, PINCE may briefly interrupt it and continue it afterwards.

Examples

Bump a value by 100:

health = address("0x7ffd1234abcd")
write_int(read_int(health) + 100, health)

NOP out an instruction found by AOB scan:

addr = aobscan_first("48 8b 05 ?? ?? ?? ?? 48 89 41")
if addr is not None:
    nop(addr, 7)
    print(f"NOPed at {addr:#x}")

Flip a conditional jump to unconditional:

addr = aobscan_first("83 f8 01 74 05")
if addr is not None:
    patch(b"\xeb", addr + 3, expected="74")

Dump all registers:

for name, value in regs().items():
    print(f"{name} = {value}")

Run a raw GDB command:

print(gdb("info proc mappings"))

Templates

The Templates menu inserts a starter script at your cursor.

Use them as skeletons where the boilerplate is done for you and the bits you need to fill in are obvious.

Most of them come as [ENABLE]/[DISABLE] pairs that are ready to be sent to the cheat table where the enable half does the thing and the disable half puts it back. Read/write address is the odd one out and stays a plain one-shot script.

Template What it does
Read/write address The simplest one: resolve an address, read an int, write it back changed.
AOB scan + NOP Scan for a pattern and NOP out the matched bytes, then restore them on disable.
AOB scan + patch Scan for a pattern and patch an instruction on enable, restore it on disable. The example turns a -1 decrement into a +2 so a value climbs instead of dropping.
Code injection The full detour: read the target instruction, allocate a code cave near the target, jump there, run your code, optionally re-run the original, jump back. The disable half restores the original bytes and frees the cave.

A word on code injection safety

The Code injection template handles the boring math like instruction-boundary alignment, cave layout and return-jump construction, but two things can still bite you when more than your target instruction gets pulled into the stolen window:

  • RIP-relative loads in stolen bytes. When stolen instructions like mov rax, [rip+0x1234] get relocated into the cave, the [rip+...] displacement now points to garbage. Same for lea rax, [rip+...] and friends. These will silently load wrong data or segfault when the cave runs.
  • Relative branches in stolen bytes. jmp rel32, jcc rel32 and call rel32 all encode their offset relative to the original PC, so they jump to the wrong place after relocation.

The template passes near=target to alloc_cave() so PINCE finds a cave within relative-jump range. If it cannot map a suitable nearby page, allocation fails instead of accepting a faraway address. This protects the jump distance, but it does not relocate position-dependent instructions copied into the cave.

Add print(disassemble_bytes(original, target)) before the patch() call to see what is being stolen. If the disassembly contains any [rip+...], jmp, call or jcc, pick a different target where the surrounding instructions are plain ALU/load/store ops.

Clone this wiki locally