-
Notifications
You must be signed in to change notification settings - Fork 193
Libpince Engine
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.
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.
- Attach to a process first
- Open
Memory View - Go to
Tools -> Libpince Engine
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
.. PressCtrl+Spaceto force it. Arrow keys navigate,EnterorTabaccepts,Escdismisses. -
Tooltip help: type
(after a function name to see its signature and docstring. -
Open / Save:
Ctrl+O/Ctrl+Sor use theFilemenu. -
Smart indent:
TabandShift+Tabwork on multi-line selections. - Syntax highlighting: keywords, builtins, namespaces, strings including f-strings, comments and numbers.
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.
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]orCtrl+Shift+Eruns the prelude plus the[ENABLE]half. -
Run -> Run [DISABLE]orCtrl+Shift+Druns 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.
- 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+Ron 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.
- This is useful while iterating. You can prepare some state in one run, then fiddle with it via
- 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.
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
addrorcaveyou 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.
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.
-
read_int(address, size=4, signed=False)- Read an integer at an address. Size
1is int8,2is int16,4is int32,8is int64.
- Read an integer at an address. Size
-
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)andwrite_bitfield(value, address, bits=1, start_bit=0, signed=False)-
bitsis the number of bits in the field.start_bitis its position inside the first byte, from0through7. - Set
signed=Trueto 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)andwrite_float(value, address, double=False)- Same idea as the int functions. Set
double=Truefor Float64. Otherwise Float32 is used.
- Same idea as the int functions. Set
-
read_string(address, length=128, encoding="utf8", zero_terminate=True)- Read a string with the given max length and encoding.
-
zero_terminate=Truemeans 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,utf16andutf32.
-
write_string(value, address, encoding="utf8", zero_terminate=True)- Write a string to an address.
-
zero_terminate=Trueappends the null terminator while writing. Turn it off if the target buffer is not meant to be null terminated.
-
read_bytes(address, length)andwrite_bytes(data, address)- Read/write raw bytes, also called AOB/Array of Bytes.
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(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")oraddress("&player1")
- An integer:
- Every read/write function above runs its
addressparameter through this internally, so you can pass any of these forms directly toread_int,write_bytes,patchand similar helpers. You only need to calladdress()yourself when you want the resolved integer for arithmetic.
- Resolves anything address-shaped to an
-
patch(data, address, expected=None)- Patch instruction bytes at the target address.
datacan bebytes,bytearray,list[int]or a hex string like"90 90 90". - If
expectedis provided, the original bytes ataddressare 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. -
expectedshould describe the same number of bytes you are replacing. - Can be undone with
restore(address).
- Patch instruction bytes at the target address.
-
nop(address, length)- Replace
lengthbytes starting ataddresswithNOPinstructions. - Can be undone with
restore(address).
- Replace
-
restore(address)- Reverts a previous
patch()ornop()at the given address. It restores the bytes PINCE saved the first time that address was patched.
- Reverts a previous
-
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.
-
patternis whitespace-separated hex tokens. Use?or??for wildcard bytes:"48 8b 05 ?? ?? ?? ??". -
writable=Truerestricts the scan to writable regions,writable=Falseexcludes them,Noneincludes both. Same forexecutable. - Useful filters are
executable=True, writable=Falsefor code andexecutable=Falsefor data. -
limitcaps the number of results. PassNonefor 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 orNoneif nothing matched. Use this when you only want one hit and do not want list indexing.
- Same as
-
alloc(size, name=None)- Allocates
sizebytes 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.
- Allocates
-
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.
- Frees memory previously allocated with
-
alloc_cave(size, name=None, near=None)- Allocates
sizebytes of executable memory viammapand returns its address. Use this for code caves. -
nearasks 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 returns0if 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.
- Allocates
-
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 mixdealloc()anddealloc_cave().
- Frees a code cave previously allocated with
-
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)
-
addressmatters for position-dependent instructions likejmp rel32orcall 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.
- Assemble x86 instructions and return the resulting bytes. Multiple instructions can be separated by
-
disassemble_bytes(data, address=0)- The inverse: take raw bytes and return instructions separated by
;. - Same
addressrules asassemble()apply. It affects how relative branches and RIP-relative operands are printed.
- The inverse: take raw bytes and return instructions separated by
-
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
Noneif the name is missing or ambiguous. - Raises if no process is attached.
-
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.
- Returns a dict of registers and their current values, for example
-
reg(name)- Returns the value of a single register. The leading
$is optional, so bothreg("rax")andreg("$rax")work.
- Returns the value of a single register. The leading
-
set_reg(name, value)- Sets the value of a register through GDB. Leading
$is optional, same asreg().
- Sets the value of a register through GDB. Leading
-
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")orgdb("bt full").
- Run an arbitrary GDB command and return its output as a string. Use this for anything the alias layer does not cover, for example
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()andnop()touch the target process memory. -
gdb("call ..."),alloc(),dealloc(),alloc_cave()anddealloc_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.
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"))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. |
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 forlea rax, [rip+...]and friends. These will silently load wrong data or segfault when the cave runs. -
Relative branches in stolen bytes.
jmp rel32,jcc rel32andcall rel32all 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.