Decompiler

Ionize lifts Luau bytecode back to readable Luau. The important part for scripts is that decompilation is asynchronous: a call that needs source yields while workers run in the background, then resumes when the result is ready.

How it runs

Work is scheduled as jobs. One job is one script. A scheduler queue feeds a pool of worker threads. The pool sizes itself from the CPU cores on the machine. Threads that have nothing to do wait and use no CPU time.

  • decompile schedules one script and yields until source is ready.
  • decompilebatch schedules many scripts, yields once, and returns every result in order.
  • saveinstance and saveplace collect the scripts in a save, wait for the batch, then write the place or model file.

Long decompiles do not block the calling script thread or the game. decompilerworkers() reports how many threads the pool created.

local script = getscripts()[1]
print(decompile(script))

local sources = decompilebatch(getscripts())
print(#sources, decompilerworkers())

Pipeline

Bytecode is parsed and a control flow graph is built. A structured emitter walks that graph and writes Luau. ConditionalStructurer can rewrite some if / else pairs into equivalent and / or. This is not a full SSA IR. Semantic correctness comes first. Large scripts stay close to the original behaviour.

Options

Pass a DecompilerOptions table as the second argument. DecompilerOptions.new copies the defaults so you can flip one field. DecompilerFormatter.new does the same for indent settings.

local opts = DecompilerOptions.new({
    ConditionalStructurer = false,
    DoBlockInsertionThreshold = 0,
    Formatter = DecompilerFormatter.new({ IndentSize = 4 }),
})
print(decompile(script, opts))

Control flow

ConditionalStructurer rewrites certain if / else pairs into equivalent and / or. Behaviour stays the same when the then value is known to be truthy. The pass is fast and not required for correctness. Turning it off can reduce time on some scripts.

AST

Most of these are cheap and worth leaving on. SmartVariableRenamer prefers debug names, then readable vN names. FunctionDeclarations emits function name() instead of name = function(). GuardClauses lifts an else return into an early return.

The main tunable is DoBlockInsertionThreshold. When live locals would exceed Luau's limit, a region is wrapped in do ... end. Set it to 0 to disable. That is cleaner for reading and a little faster.

Formatting

DecompilerFormatter controls indent size, tabs versus spaces, and spaces after commas. The cost is small even on very large scripts.

Saving a place

saveplace writes the attached place and turns decompile on by default. saveinstance does the same for a chosen instance. Both queue every script, wait, then write. See Scripting for the explorer button and the API reference for signatures.

saveplace()
saveinstance(game.Workspace, { Decompile = true })