atherhubather.hub
Back to Guides
SUNC
11 min read
May 11, 2026

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.

Ather
Ather
Lead developer at Atherhub. Writes about Roblox internals, Luau, script engineering, and platform security.Last updated May 11, 2026

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

luau
debug.getconstants(fn: function): { any }
debug.getconstant(fn: function, index: number): any
Returns{ any }
An array of every constant referenced by fn's bytecode, in compile order.
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.

luau
debug.setconstant(fn: function, index: number, value: any): ()
Returnsvoid
No return value. Subsequent invocations of fn use the new constant value.
debug.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.

Use sparingly
Rewriting constants of someone else's function leaves no obvious trace and can produce wildly confusing behaviour for anyone debugging. Reserve this for places where the alternative (hooking the whole function) is worse.

debug.getupvalues and getupvalue

Upvalues are values a closure captured from its enclosing scope. The debug library lets you list them and read them.

luau
debug.getupvalues(fn: function): { any }
debug.getupvalue(fn: function, index: number): any
Returns{ any }
An array of the closure's upvalues, in declaration order.
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))  --> 1

For 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.

luau
debug.setupvalue(fn: function, index: number, value: any): ()
Returnsvoid
No return value. The closure's bound value at the given index is replaced.
debug.setupvalue(count, 1, 99)
print(count())  --> 100

The 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.

luau
debug.getprotos(fn: function): { function }
debug.getproto(fn: function, index: number, active: boolean?): function | { function }
Returns{ function }
The list of inner functions defined inside fn.
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...  2

The 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.

luau
debug.getinfo(fn: function): {
    source: string,
    short_src: string,
    func: function,
    what: string,
    currentline: number,
    nups: number,
    numparams: number,
    is_vararg: number,
    name: string?,
}
Returnstable
A table of metadata about the function. The exact field set varies a little across executors, but the entries above are the SUNC-defined ones.
local 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.

luau
debug.getstack(level: number, index: number?): any | { any }
Returnsany | { any }
With both arguments, returns the value of one stack slot. With just level, returns the whole slot array for that stack frame.
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
Written by Ather

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.