The Lua panel embeds Lua 5.4 with the functions below available as plain
globals. Everything here is implemented in
src/scripting/LuaConsole.cpp.
There is one Lua state for the whole session, so a global you set in one run is still there on the next. Scripts run on a worker thread, one at a time, each on its own coroutine; a second submission while one is running is refused with "A script is already running."
The Lua Scanner panel is a separate feature with its own function(ctx)
predicate API. None of the functions below relate to it.
Value type names, case-insensitive: i8, u8, i16, u16, i32, u32,
i64, u64, f32, f64, bytes, str, wstr. The same names the MCP API
uses. Any other name raises an error.
Addresses are Lua integers in both directions.
Returned values are Lua integers, with four exceptions. f32 and f64
come back as Lua numbers, as you would expect. u64 also comes back as a
number rather than an integer, because a full-range unsigned 64-bit value would
wrap negative in a signed Lua integer. bytes comes back as an uppercase hex
string. str and wstr come back as Lua strings, cut at the first terminator.
Errors arrive one of three ways, noted per function:
- a raised Lua error, catchable with
pcall nilplus a messagefalseplus a message
Hex input (write(..., "bytes", ...), write_bytes) is parsed leniently:
every non-hex character is discarded, and an odd number of digits is left-padded
with 0. "48 8B 05", "488b05" and "48-8B-05" are the same input.
Replaces the stock print. Converts each argument with tostring semantics
(honouring __tostring), joins with tabs, and appends one line to the console.
Output appears while the script is still running.
The console panel keeps the last 10 000 lines. On overflow the oldest 5 000 are
dropped and a ... earlier output discarded ... marker is inserted in their
place. The log file is not affected.
Array of { pid = integer, name = string }, sorted case-insensitively by name.
Never fails; returns an empty table if the snapshot could not be taken.
Attaches to pid. Returns true, or false, message.
Attaching with read-only access still returns true — the degraded-access
warning goes to the log and the UI, not to Lua. If your script needs to write,
check that a write succeeds rather than trusting the attach.
Raises if pid is not an integer.
Always succeeds, including when nothing is attached. Clears the cached module and region lists.
Array of { name = string, base = integer, size = integer }. Full paths are not
exposed.
This reads a snapshot, taken when you attached and refreshed by refresh(),
by loadlibrary(), and by the UI's Refresh actions — so it is empty when
nothing is attached, and will not show a module the target loaded after you
attached until something refreshes it.
Re-reads the target's module and region lists. Returns true, or
false, message when nothing is attached. Call it before modules() or
regions() if the target may have loaded or unloaded something since you
attached.
Array of { base, size, readable, writable, executable } — three integers and
three booleans. Same snapshot caveat as modules().
Reads and decodes one value. Returns the value, or nil, message where the
message is either the OS error or "Short read." when the read succeeded but
returned fewer bytes than the type needs.
Raises on an unrecognised type name. With type = "bytes" this reads exactly
one byte; use read_bytes for anything longer.
With type = "str" or "wstr" the third argument is how many bytes to
look at, default 256 and at most 4096; the result is cut at the first
terminator inside that window, and a window that runs off the end of mapped
memory returns what could be read rather than failing. Raises if length is
outside 1–4096.
Writes one value. All three arguments are required — unlike read, type has
no default. Returns true, or false, message.
Raises on an unrecognised type name, or if value is not convertible: an
integer for the i*/u* types, a number for f32/f64, a hex string for
bytes, a string for str (written as bytes, no terminator) and wstr
(written as UTF-16, no terminator). Append "\0" yourself if the target
expects a terminated string and the old one was longer.
Shorthand for read(address, "u32"). Returns nil with no message on
failure or a short read.
Shorthand for write(address, "u32", value). Returns a bare boolean; the
failure message is not available.
Returns an uppercase, space-separated hex string ("48 8B 05"), or nil if the
read failed outright. size must be between 1 and 4096, the same cap as the
MCP read_bytes tool; anything else raises. Read a larger range in pieces.
A partial read is not an error here: you may get back fewer than size
bytes, and an empty string is possible. Check the length yourself if it matters.
Writes the parsed hex string. Returns a bare boolean, no message.
Scans run asynchronously on the scan job's thread. Starting a scan first cancels
and joins any scan already in flight, so scan_exact, scan_unknown and
scan_next can block briefly while the previous one winds down.
None of them require an attached process — an unattached scan simply finds nothing.
Starts a first scan for an exact value. Returns nothing; poll
scan_status() or call scan_wait().
Raises on an unrecognised type name, or if value does not convert to that type.
Starts an unknown-initial scan, snapshotting every eligible location as a
baseline for a later scan_next. Returns nothing.
Raises on an unrecognised type name.
Narrows the previous result set. mode is one of "exact", "unknown",
"changed", "unchanged", "increased", "decreased".
value is read only when mode == "exact", and is interpreted using the
value type of the previous scan — you cannot change type partway through a chain.
Returns true, or false, "There are no results to narrow. Run scan_exact or scan_unknown first." Raises on an unrecognised mode.
"unknown" as a next-scan mode matches everything: it re-reads the current
values without filtering, which is how you refresh a result set in place.
Never fails. Returns:
| Field | Type | Meaning |
|---|---|---|
running |
boolean | The scan thread is still working |
results |
integer | Results found so far |
fraction |
number | Progress, 0.0 to 1.0 |
truncated |
boolean | The result cap stopped the scan early |
status |
string | Human-readable status line |
truncated is distinct from cancelled: it means there were more matches than
the result limit allowed.
Polls every 10 ms until the scan is idle. Returns true — immediately, if no
scan was ever started — or false, "Timed out waiting for the scan."
Ends the script if it is cancelled while waiting. That cannot be caught with
pcall — see Cancellation.
Returns two values: an array of at most limit results, and the total number
of results held. The total is how you detect that you are looking at a partial
view.
Each element:
| Field | Type | Meaning |
|---|---|---|
address |
integer | |
value |
typed | Current bytes decoded with the scan's value type |
hex |
string | Current bytes as uppercase hex, no separators |
Note that hex here has no spaces, while read_bytes returns spaced hex. The
previous-scan bytes are not exposed.
Walks a pointer chain and returns the address it currently points at. The module is looked up by name at call time, which is what makes a chain survive a restart under ASLR.
offsets must be a sequence (array-style table) of integers; a non-numeric entry
silently reads as 0. Each offset dereferences the current address and then adds
the offset, so the final offset is added, not dereferenced — the returned
address is where the value lives.
Returns the address, or nil, message when nothing is attached, the offsets
table is empty, the module is not loaded, or a pointer partway along is null or
unreadable.
-- helper.exe+0x3040 -> +0x10 -> +0x8
local addr, err = resolve("helper.exe", 0x3040, { 0x10, 0x8 })
if addr then print(string.format("%X = %d", addr, read(addr, "i32"))) else print(err) endAdds an entry to the address list and returns its id. If a process is attached and the type has a fixed size, the current value is captured as the entry's freeze value.
Raises on an unrecognised type name.
These three go through the injector and fail with "No target process attached." when nothing is attached. All of them can destabilise or crash the
target — that is the nature of running code inside someone else's process.
Allocates size bytes in the target as PAGE_EXECUTE_READWRITE. This is not
configurable from Lua; if you want a different protection, use the Injection
panel.
Returns the base address, or nil, message. There is no free counterpart in
the Lua API — the allocation lives until the target exits.
Creates a thread in the target at start and waits up to 5 seconds for it.
Returns true, exit_code or false, message.
A thread still running after 5 seconds returns false with a message saying so,
rather than reporting a bogus exit code. The thread keeps running.
Loads a DLL into the target via a remote LoadLibraryW. Returns
true, base or false, message.
The integer is the module's base address, looked up by filename after the
load. The module list is refreshed as part of this, so the newly loaded DLL is
also visible to modules() immediately afterwards.
If the load succeeds but the module cannot then be found in the list, the remote
thread's exit code is returned instead — the low 32 bits of the module handle,
which is not a usable address. Compare against modules() if you need to be
certain.
Fails with an explicit message on a 32-bit (WOW64) target.
Six functions that drive the user interface, so that a set of figures can be captured by running a file rather than by a person with a screenshot key. They exist because a figure captured by hand is correct on the day it was taken and silently wrong afterwards: a panel gets renamed, a control moves, and nobody finds out until a reader follows an instruction that no longer matches the picture beside it.
All six return true, or false, message. All six block until the window has
actually done the work — a call that returned before the change was on screen
would defeat the whole purpose — and all six return
false, "There is no window to drive." in a build with no frontend attached.
They pair with the --script flag:
PointerLab.exe --script scripts\capture-figures.lua
which runs a script once the window is up. It is the same language, the same sandbox and the same console; the flag only saves you pasting it in.
Writes the window's back buffer to path as a PNG, creating the parent
directory if needed. A relative path is relative to the working directory.
This captures the window, not the screen, so nothing behind it and no
notification that happens to appear can end up in a figure. The cost is that a
panel dragged out into its own OS window will not be in the picture — see
set_layout below.
It is the one exception to the sandbox, which otherwise removes io.
It writes one file, of one format, holding a picture of this program's own
window; it is not a way back to arbitrary writes.
Opens the named panel if it is closed and brings it to the front of its tab bar.
The name is the panel's title exactly as it appears in the View menu — "Access Watch", "Speed and Export", "Pointer Scanner".
A name that is not a panel fails rather than doing nothing, which is the point: that is how a renamed panel is discovered by a script run rather than by a reader.
"default" restores the shipped arrangement. It is the only name there is, and
the only arrangement a figure should be captured in — everything is docked in
it, so everything ends up in the back buffer.
Sets the client size, between 320×240 and 8192×8192. Client rather than window, because the frame around it differs between Windows versions and themes and is not in the picture anyway.
Worth doing first in any capture script: two figures taken a year apart are then the same number of pixels, and a reader comparing them is comparing the tool rather than somebody's window manager.
Lets n frames be drawn before the script continues, up to 600.
Not optional in a capture script. Opening a panel takes effect on the next frame and its docking settles on the one after that, so a screenshot taken immediately catches the layout mid-move. A dozen frames is a comfortable margin.
Closes the window. The usual exit path runs, so the session is autosaved as it would be if you had closed it yourself.
These are removed before your script runs:
- Globals:
io,package,require,dofile,loadfile - From the registry's loaded-module table:
ioandpackage, so they cannot be fetched back from there - From
debug:getregistry - From
os:execute,remove,rename,tmpname,exit,getenv,setlocale
Everything else from the standard library remains, including os.time,
os.clock, os.date, os.difftime, load, string, table, math,
coroutine and the rest of debug.
The Lua Scanner panel runs its function(ctx) predicate in a separate Lua
state, and that state gets exactly the same sandbox and the same cancel hook.
This is a guard against a careless script, not a security boundary. load is
still present, and a memory-editing tool hands you the ability to write
arbitrary bytes into another process regardless. Do not run a script you have
not read.
Your script runs on its own coroutine. Stop sets a flag that three things watch:
a VM hook that fires every 10 000 instructions, scan_wait's 10 ms poll, and
check_cancel. Any of them yields, which ends the script — Pointer Lab
simply never resumes the coroutine, and prints Script cancelled.
A cancel cannot be caught. pcall and xpcall catch errors; a yield passes
straight through them. while true do pcall(f) end stops when you press Stop.
Stop also cancels a scan the script started, so the work stops with the script rather than continuing in the background.
Still worth knowing:
- Time inside a C function is not interruptible. A long
read_bytes, or the join at the start of a scan, runs to completion. Only Lua instructions,scan_waitandcheck_cancelobserve the flag. - The Lua state is shared between runs, so globals set by a cancelled script are still there on the next one.
Whether Stop has been pressed. Useful inside a loop that spends its time in C functions, where the VM hook rarely gets a turn:
for i = 1, 100000 do
if cancelled() then print("stopping early") break end
read_bytes(base + i * 16, 16)
endEnds the script immediately if Stop has been pressed, and does nothing
otherwise. Like the hook, this cannot be caught with pcall.
-- Find a known i32, narrow it after it changes, and freeze what is left.
if not attach(4812) then print("could not attach") return end
scan_exact(100, "i32")
scan_wait()
print(scan_status().results .. " candidates")
print("change the value in the target, then run the rest")
scan_next("decreased")
scan_wait()
local results, total = scan_results(10)
print(total .. " remain; first " .. #results .. ":")
for _, r in ipairs(results) do
print(string.format(" %X = %s", r.address, tostring(r.value)))
end
if total == 1 then
add_address(results[1].address, "i32", "Found by script", "Lua")
end