The SUNC debug library
Every compiled Luau function is more than just executable code — it carries a table of constants, a list of upvalues, references to its nested prototypes, and source-location metadata. SUNC's debug library exposes all of that, lets you read it, and in some cases lets you change it.
What's actually inside a Lua function
When the Luau compiler produces bytecode, it bundles three things together into a single object called a proto:
- Bytecode — the actual instructions.
- Constants — every literal value referenced in the function (strings, numbers, booleans).
- Nested protos — inner functions defined inside this one.
A function value at runtime is a closure: a proto plus the values its upvalues currently bind to. The debug library hands you the toolkit to inspect every part of that picture.
debug.getconstants and getconstant
debug.getconstants(fn: function): { any }
debug.getconstant(fn: function, index: number): any{ any }local function greet(name)
print("Hello, " .. name .. "!")
end
for i, k in debug.getconstants(greet) do
print(i, type(k), k)
end
--> 1 string "print"
--> 2 string "Hello, "
--> 3 string "!"The constants you see in greet include both the literal strings the function builds with and the names of globals it calls. print appears as a constant because the function looks it up by name at runtime — that's how Luau global lookups work under the hood.
debug.setconstant
The constant table isn't just readable — you can rewrite entries.
debug.setconstant(fn: function, index: number, value: any): ()voiddebug.setconstant(greet, 2, "Goodbye, ")
greet("world")
--> Goodbye, world!This is the kind of mutation that produces magical-looking rewrites: change a constant and every existing call site of that function picks up the new value. It works because Luau stores constants by index, not by value, and the bytecode that fetched index 2 still fetches index 2.
debug.getupvalues and getupvalue
Upvalues are values a closure captured from its enclosing scope. The debug library lets you list them and read them.
debug.getupvalues(fn: function): { any }
debug.getupvalue(fn: function, index: number): any{ any }local function makeCounter()
local n = 0
return function()
n += 1
return n
end
end
local count = makeCounter()
count()
print(debug.getupvalues(count)) --> { 1 }
print(debug.getupvalue(count, 1)) --> 1For closures generated by your own script, this is mostly curiosity. The real use is reading upvalues of game-side closures — connection callbacks, deferred-task functions, or local state inside a module — to learn what they're actually holding.
debug.setupvalue
Same idea as setconstant but for upvalues.
debug.setupvalue(fn: function, index: number, value: any): ()voiddebug.setupvalue(count, 1, 99)
print(count()) --> 100The counter is now at 99 from the moment we wrote it; the next call increments to 100. This is the same shape of mutation assetconstant but happens at the closure level rather than the proto level.
debug.getprotos and getproto
Inner functions are reachable through the parent's proto list.
debug.getprotos(fn: function): { function }
debug.getproto(fn: function, index: number, active: boolean?): function | { function }{ function }local function outer()
local function inner1() return 1 end
local function inner2() return 2 end
return inner1, inner2
end
for i, p in debug.getprotos(outer) do
print(i, p, p())
end
--> 1 function: 0x... 1
--> 2 function: 0x... 2The active parameter of getproto controls whether you get the prototype (a fresh function value not bound to any upvalues) or all currentlyactive closures derived from that prototype. Active mode is how you find every existing instance of an inner function across the call tree — useful when you need to reach into a specific captured upvalue without re-invoking the outer function.
debug.getinfo
Source-level metadata: where a function was defined, what line, how many upvalues it has, its name in its parent scope.
debug.getinfo(fn: function): {
source: string,
short_src: string,
func: function,
what: string,
currentline: number,
nups: number,
numparams: number,
is_vararg: number,
name: string?,
}tablelocal info = debug.getinfo(greet)
print(info.source) --> "=Players.LocalPlayer.PlayerGui.Script"
print(info.numparams) --> 1
print(info.nups) --> 0
print(info.what) --> "Lua" ('C' for C closures)The what field is one of the cheapest ways to tell C and Lua closures apart, identical in effect to iscclosure. The source field is extremely useful when you have a closure and need to find what script created it — even from inside a deferred callback whose parent has long since returned.
debug.getstack and setstack
Whereas the previous functions operate on a function value,getstack operates on a stack frame — i.e. the local-variable slots of a currently-running invocation.
debug.getstack(level: number, index: number?): any | { any }any | { any }local function inner()
local x, y, z = 1, 2, 3
print(debug.getstack(1, 1), debug.getstack(1, 2)) --> 1 2
end
inner()Level 1 is the caller of getstack; level 2 is the caller's caller, and so on. The index is the slot number within that frame. setstack(level, index, value) is the mutating counterpart, and it's how scripts occasionally fix up a value in someone else's currently- running function — extremely powerful and extremely dangerous.
Patterns and gotchas
- Constants are not sequential. The compiler reorders and deduplicates. Don't assume constant N is the Nth literal you typed.
- C closures have empty tables.
getconstants(print)returns an empty list. Most functions you'll meaningfully inspect are Lua closures. - Mutation has identity consequences. Anti-detection routines that fingerprint constants and upvalues will see your changes. Plan accordingly.
- Stack levels are zero-indexed for getinfo, one-indexed for getstack. Easy to mix up. Read both signatures carefully.
Wrap-up
The debug library is the SUNC primitive that lives closest to the Luau VM. It's the right tool when you need to read or rewrite the "data" part of someone else's function — constants and upvalues — without replacing the function itself. For replacing functions wholesale, reach for hookfunction. For both, read both articles.
Ather is the lead developer behind Atherhub. He's been writing Luau and Roblox tooling for the better part of a decade, with a focus on the messy interface between game-script internals and the platforms that host them. Have feedback on this article? Drop it in the Discord.