Love2D analysis + cheating toolset
"Universal" Love2D (LÖVE / LuaJIT) Cheat Toolkit (x64 only)
A set of Cheat Engine scripts for building trainers for any Love2D game - GC64 and non-GC64 LuaJIT builds - without hunting a single pointer. Instead of chasing GC-relocated addresses, the toolkit hijacks the game's own Lua state and lets you run arbitrary Lua inside the game, driving everything (fly, god mode, infinite resources, unlocks, money) through the game's own functions and variables.
If you've tried to hack a Love2D game the normal way, you already know why this exists: the values live in Lua tables on a garbage-collected heap, so their addresses move every launch and pointer scans return nothing stable. The fix is to stop treating it as a memory-address problem and treat it as a scripting problem.
What's in the toolkit
Hook (GC64) - auto-detecting generator. Installs a lua_gettop hook that captures the Lua state, then wraps love.update with a render-safe, cached command runner. For the common modern LÖVE build.
Hook (non-GC64) - auto-detecting generator for older/32-bit-pointer LuaJIT builds where the GC64 approach can't run in-engine. Captures the state and executes commands through a small assembled stub.
Globals Dumper - lists the entire _G namespace.
Table Inspector - dumps any table's fields (with a full path), safely.
Function Sniffer - reports a function's parameter count, vararg flag, source location, and (where the build exposes them) parameter names.
Cheat Generator (UI) - a small window: enter a variable/function path, a type, and a value; it builds a toggleable cheat entry for you.
The core idea:
Every Lua program runs inside a lua_State - the C struct that holds the entire VM: stack, globals, GC, everything. Nearly every function in Lua's C API takes a lua_State *L as its first argument.
So the plan is:
Hook a Lua C function that gets called constantly (lua_gettop is perfect) and steal its first argument. That's a live lua_State.
Using that state, call luaL_loadstring(L, "your lua code") then lua_pcall(L, ...) to compile and run any Lua string you want.
Inside that injected Lua, use the game's own globals, tables, and LuaJIT's FFI to read/write anything and to shuttle results back out to Cheat Engine.
Once step 2 works you have, in effect, an in-game Lua console. Everything after is just writing Lua.
The development process (how it was found)
This is the part worth reading if you want to understand why the scripts look the way they do, or adapt them to a stubborn game. It was built empirically, mostly in Cheat Engine's Lua Engine, one confirmed fact at a time.
1. Confirm it's LuaJIT and find the C API
Attach, open the Lua Engine, and check for the exports:
Code: Select all
print(getAddress("lua51.lua_gettop"))
print(getAddress("lua51.luaL_loadstring"))
print(getAddress("lua51.lua_pcall"))
print(getAddress("lua51.lua_settop"))Love2D ships LuaJIT as lua51.dll. If those resolve, you can call them by name and skip AOB scanning entirely. If they don't, AOB-scan for lua_gettop's body; on x64 LuaJIT it's the recognizable "mov rax,[rcx+X] / sub rax,[rcx+Y] / sar rax,03 / ret" shape.
2. Read the state-struct offsets
Disassemble lua_gettop. Its whole body is the calculation (L->top - L->base) / sizeof(TValue), which exposes two offsets into lua_State:
Code: Select all
mov rax,[rcx+28] ; L->top
sub rax,[rcx+20] ; - L->base
sar rax,03 ; / 8
retThose two offsets are the single biggest per-build variable, and reading them is also how you tell GC64 from non-GC64: large offsets (like +28/+20) are GC64; small offsets (like +18/+10) are non-GC64 (a smaller struct because pointers are packed differently). The generators read these automatically and adapt.
3. Capture the state
Hook lua_gettop's entry with a cave that does one thing before running the original body: mov [stateStore],rcx. Because lua_gettop fires thousands of times a second, stateStore fills within a frame. Read it back:
Code: Select all
print(string.format("%X", readQword(getAddress("stateStore"))))A nonzero value means you've captured a live lua_State.
4. Prove execution + set up readback
Capturing isn't executing. Test with a command, and set up a way to read results back - Love2D games rarely have a visible console, so use LuaJIT's FFI to write a result into a buffer Cheat Engine can read:
Code: Select all
gcrun([[
local ffi = require("ffi")
ffi.cast("double*", <BUFFER_ADDR>)[0] = 31337
]])
-- then: readDouble(<BUFFER_ADDR>) -> 31337 if it workedWhen that returns 31337, you have arbitrary code execution inside the game, plus FFI read/write to any address. That's the milestone.
5. Explore the game as a Lua program
Now you reverse the game as Lua, which is enormously easier than as raw memory. Dump the globals to see the whole namespace, then drill into the interesting tables by full path. Look for player/world/game/director/currLevel, an entity or actor registry, a save/progress table, currency, and - very often - developer debug flags left in (GOD_MODE, UNLOCK_WEAPONS, creative, dev_unlock_all). Those flags are frequently one-line cheats.
Reaching nested data is just building the full path from something reachable:
Code: Select all
game.g.ingredients_list -- a table inside a table inside the global 'game'
getPlayerActor().weapon -- the result of a call, then a field6. Modify through the game's own systems
The golden rule: go through the game's setters and methods, not raw writes. Setting player.y through the game's setY() keeps collision, animation, and state machines intact; poking raw memory clips you through the world. For things the game overwrites each frame (health, ammo, position), pin them on a timer.
To beat gravity for a fly cheat, you don't just set position - you also neutralize whatever the physics step re-applies. Inspecting the player's velocity table reveals the real fall field to zero each frame (often nested, e.g. velocity.vertical.current), which is why inspecting sub-tables matters.
Two execution routes (and why there are two)
GC64 route - the love.update wrapper
On modern GC64 builds you can run commands on the game's own thread by wrapping love.update. The hook captures the state, then a one-time bootstrap replaces love.update with a version that checks a queued command each frame, runs it, and calls the original. This is render-safe (it runs at a clean point in the frame) and uses a compile-cache so a repeated command compiles once and is reused, keeping long sessions light.
Two details that matter, learned the hard way:
Cache only successful compiles. If you cache a compile failure, one transient bad command poisons that command forever (the classic "works until you reload the level" bug). Retry on failure; cache only success.
Gate execution to a safe stack depth. lua_gettop is sometimes called from inside the draw path. Running a command there corrupts rendering. The hook measures the shallowest stack depth the game reaches (the frame baseline) and only executes at/below it - so commands never fire mid-draw.
non-GC64 route - the executeCodeEx stub
On non-GC64 builds, re-entering the VM from inside the lua_gettop hook throws a LuaJIT runtime error that eventually tears the VM down. The fix is to capture the state in the hook (safe - just a store) and execute separately via a small hand-assembled stub that Cheat Engine calls with executeCodeEx. The stub does luaL_loadstring + lua_pcall + lua_settop(L, 0) in a single call.
Every part of that stub earned its place through failure:
Far calls via a register. call <dll_addr> doesn't encode for a distant address on x64. Use mov r10, <addr> then call r10.
Stack alignment. and rsp,-10 before reserving shadow space, or the call faults.
One shared stack. loadstring pushes the chunk; pcall runs it. They must happen in the same call so they share one Lua stack - two separate executeCodeEx calls don't, and pcall ends up calling garbage.
Clean the stack. lua_settop(L, 0) after each command, or residue and error objects accumulate and eventually crash the VM.
Fresh state each call, correct symbol names (lua51.luaL_loadstring, not lua51.dll.luaL_loadstring).
Miss any one of those and you get a delayed crash that looks like anti-tamper but isn't - it's just an unbalanced or misaligned call. (A RaiseException with code 0xE24C4A02 is not a crash at all; that's LuaJIT's normal internal error SEH. Don't enable "break on exceptions" - it halts on the VM's own normal error handling and makes things worse.)
Buffer & paging notes
The readback buffer only reliably carries a limited slice per read, so anything larger than that (a globals dump, a big table) must be built into a game-side string once, then paged back in small chunks. Keep chunked reads modest and don't tack a cleanup call onto the end of a big dump - build once, page in small reads, stop. This is why the dumper and inspector look the way they do.
For installing anything larger than the command buffer (the wrapper bootstrap, an enumerator function), stream it in small pieces that are concatenated game-side and then compiled once. No single delivered command ever approaches the buffer limit.
Usage
Attach Cheat Engine to the game.
Tick the GC64 hook generator. If commands execute cleanly, you're done. If the game is non-GC64 (small lua_gettop offsets) or the GC64 route can't execute, tick the non-GC64 hook instead.
Tick the generated hook entry; play for a second or two so it captures the state and comes online. The console will confirm when it's ready.
Use the tools: Dumper for the global namespace, Inspector (set a TARGET path) for any table, Sniffer (set a function TARGET) for signatures, and the Cheat Generator UI to build toggleable cheats from a path + type + value.
To find your targets, dump globals, then inspect the promising tables by full path until you reach the value or function you want. Boolean debug flags are toggles; numbers get set or pinned; functions get called with the args the Sniffer reveals.
If you experience a crash, you must restart both the game and cheat engine and re-hook to clear the stale stub saved into cache.
Limitations / notes
x64 only. 32-bit Love2D games use a different calling convention and aren't supported by these hooks as written.
The non-GC64 hook has the state-struct offsets and restore bytes baked in by its generator from the game's actual lua_gettop; if you hand-edit for a new game, swap those two offsets and the restore-byte line. (haven't needed to do this hence baking bytes, but just in case)
Switching between a GC64 and a non-GC64 game in one session: re-enable the matching hook so the shared command interface is redefined for that build. A fresh Cheat Engine restart between different games is the clean way.
Only use on single-player / offline games. Injecting code into a multiplayer client is how you get banned, and manipulating authoritative state is a different and worse idea.
This toolset was tested on the following games;
GC64: Intravenous 2, Gravity Circuit, Snacktorio, Blue Revolver
NonGC64: Kingdom Rush 5: Alliance
Why this beats pointer scanning for Love2D
No pointer chains to find or maintain - nothing to break on the next patch.
You address things by their names in the game's own code, which are stable across launches even though addresses aren't.
You go through the game's real setters, so collision, physics, and game state stay consistent.
The whole technique reduces to a single leverage point: the lua_State is the game. Capture it, get an in-game console, and from then on you're not hacking memory - you're scripting the game from the inside, in the same language its own developers wrote it in.
Disclosure:
AI was used in the making of this project when running into points of frustration to help diagnose issues along the way. Human written, AI assisted. This write-up post was also co-authored by AI to ensure nothing was forgotten (and to ensure accuracy). Thanks, hope you enjoy the tools and find them useful (this is a whole week of my life I won't be getting back haha)!