Skip to content

Working with FFI

With Enable FFI checked, LuaJIT ffi is loaded into the script env. Realistically:

  • Declare and call exported C APIs from libraries already mapped in the CS2 process (libc, etc.) when you know the symbol and ABI.
  • Pack/unpack binary layouts you reverse yourself (structs, bitfields) for one-off experiments.
  • Pointer arithmetic on addresses you already obtained from a trusted path (e.g. a value you printed from schema / a module base you resolved carefully).
  • Performance-sensitive math or bulk buffer walks without crossing into C++ bindings for every field.
  • Calling C++ member functions / virtual methods (thiscall, vtables, name mangling, Itanium ABI on Linux).
  • Finding reliable module bases of libclient.so without a binding (aqua does not currently expose dl_iterate_phdr helpers).
  • Anything that must stay VAC/thread-safe - wrong-thread calls and bad pointers crash the game, not just the script.
  • Writing random process memory “because it might be a cvar”.
  • Loading extra .so files from disk (package.loadlib / ffi.load of untrusted paths).
  • Long-lived ffi callbacks from engine threads you do not own.

Prefer first-class bindings instead of FFI when possible

Section titled “Prefer first-class bindings instead of FFI when possible”

If many scripts need the same thing (input, items, steamId, weapons), it should be a normal aqua.* API - safer, versioned, and no ABI guesswork. FFI is the escape hatch for advanced authors, not the default toolbox.

Example shape (illustrative only - still requires correct addresses/types):

-- Requires Enable FFI
local ffi = require("ffi")
ffi.cdef[[
int puts(const char *s);
]]
-- ffi.C.puts("hello from ffi") -- libc, usually present