# unreadable.gg > unreadable.gg turns Luau bytecode back into readable source. You can use it on the site, from an executor, over HTTP or over MCP. It aims for the same program, and its `faithful` preset aims for the same bytes. Where it can't be sure, it marks the spot or refuses that function instead of guessing. The API and the MCP server default to `faithful`. The site starts on `balanced`. The executor client defaults to `balanced`. The MCP server at POST https://api.unreadable.gg/mcp is stateless, speaks only revision 2026-07-28, and refuses clients that send initialize. Every connection gets 100 free decompiles a day, with no key or account. Errors and refused decompiles don't count, except `timed-out`. Send the compiled file as the request body; the response is JSON with the source, a verdict, notes and the setting it was made with: ```sh curl https://api.unreadable.gg/v1/decompile \ -H "Content-Type: application/octet-stream" \ --data-binary @module.luac -o result.json ``` ## Docs - [Everything in one file](https://unreadable.gg/llms-full.txt): every page below, joined. - [Docs overview](https://unreadable.gg/docs.md): a quickstart, what the verdicts mean, and the presets in a sentence. - [API](https://unreadable.gg/docs/api.md): the request (the file as it is, or base64 in JSON), the response, options, note codes, error codes, limits, the free tier, keys and accounts, and the decompiler version. - [Readability](https://unreadable.gg/docs/readability.md): the presets (faithful by default), faithful layout, and what each readability switch prints. - [Stream](https://unreadable.gg/docs/stream.md): `wss://api.unreadable.gg/v1/stream`, many decompiles over one WebSocket, and the executor client at https://unreadable.gg/decompile.lua. - [Agents](https://unreadable.gg/docs/agents.md): llms.txt and markdown pages, the OpenAPI schema, and the MCP server (`POST https://api.unreadable.gg/mcp`, stateless, its tools and handles; `POST /v1/handles` takes the file from disk and answers a handle, so its bytes never pass through the model). - [OpenAPI schema](https://api.unreadable.gg/v1/openapi.json): the whole request and response schema. ## About - [Luau decompiler](https://unreadable.gg/luau.md): what it reads (Luau bytecode versions 1 to 14, the pre-release format, RSB1-wrapped modules, and place files opened in the tab), what you get back, and how sure it is. - [Lua versions](https://unreadable.gg/versions.md): which Lua versions are read today (only Luau). - [Changelog](https://unreadable.gg/changelog.md): every release, newest first, by its decompiler version, with a title and what changed. - [Privacy](https://unreadable.gg/privacy.md): what's kept, for how long, and who handles it. - [Terms](https://unreadable.gg/terms.md): the rules for using the site and the API. --- # unreadable.gg docs This is a decompiler for [Luau](https://unreadable.gg/luau) bytecode. It turns compiled scripts back into readable source, and marks anything it isn't sure about instead of guessing. ## quickstart ### on the site Drop a compiled file on [the home page](https://unreadable.gg). `.rbxl`/`.rbxm` files work too (or their XML versions). They open in your browser, only the bytecode is sent, and you get the file back with every script decompiled. ### on discord In [our Discord server](https://discord.gg/8gXdq43sK8), run `/decompile` with your file attached. You get the source back as a `.luau` file only you can see. It starts on `balanced`, or pick another preset in the command. You need the Verified role (`/verify`) and an account at least 7 days old. ### in an executor Run this, then use `decompile` like you normally would. Your executor needs `WebSocket.connect` and `getscriptbytecode`. Options, keys and errors are on the [stream page](https://unreadable.gg/docs/stream#client). ```lua shared.UNREADABLE_KEY = "ugk_..." -- optional: decompile as your account loadstring(game:HttpGet("https://unreadable.gg/decompile.lua"))() ``` ### over http Send the compiled file as the body. You get JSON back with the source, a verdict and notes. The [API page](https://unreadable.gg/docs/api) has Python, JavaScript and options. ```sh curl https://api.unreadable.gg/v1/decompile \ -H "Content-Type: application/octet-stream" \ --data-binary @module.luac ``` ```powershell # Windows, in PowerShell curl.exe https://api.unreadable.gg/v1/decompile ` -H "Content-Type: application/octet-stream" ` --data-binary "@module.luac" ``` ## what's the price? You get 100 free decompiles a day, no account needed. The site, the executor and the API share them, and they reset at midnight UTC. One connection is one IP address (with IPv6, one /64 range). The Discord bot has the same limit, counted per Discord account. ## verdicts Every result has a verdict. It tells you how much to trust the source. | verdict | meaning | | --- | --- | | decompiled-unverified | Everything decompiled, nothing marked. It isn't recompiled or run to check, so it's our best answer, not a proof. | | marked | Some spots couldn't be printed exactly. Each one gets a `-- unreadable:` comment instead of a guess. | | refused | Some functions couldn't be decompiled. Each one is a stub that errors if called, with its instructions beside it. Everything else is still there. | The [notes](https://unreadable.gg/docs/api#notes) say what was marked or refused, and where. ## which preset should I use? Use `balanced` if you don't want to think about it. Use `faithful` if you want source that compiles to the same bytes, or stack traces that point into the right function. Just know it can be quite verbose. Use `readable` if you want the cleanest result. It turns every switch on, so names, types and line numbers can differ from the original. The site, the executor and the Discord bot start on `balanced`. The API and the MCP server start on `faithful`. Every preset runs the same as your original. Details are on [readability](https://unreadable.gg/docs/readability). ## the other pages - [api](https://unreadable.gg/docs/api): requests, responses, options, errors, limits, keys and accounts. - [readability](https://unreadable.gg/docs/readability): the presets, and what each switch does. - [stream](https://unreadable.gg/docs/stream): the executor client, and many decompiles over one WebSocket. - [agents](https://unreadable.gg/docs/agents): markdown pages, the OpenAPI schema and the MCP server. --- # unreadable.gg API One request decompiles one script and gives you JSON back. For `.rbxl`/`.rbxm` files, use [the site](https://unreadable.gg). For lots of scripts at once, use the [stream](https://unreadable.gg/docs/stream). ## request `POST https://api.unreadable.gg/v1/decompile` Send the script's bytecode as the body, with `Content-Type: application/octet-stream`. Every [format](https://unreadable.gg/luau#reads) on the Luau page works, RSB1-wrapped files included. ```sh curl https://api.unreadable.gg/v1/decompile \ -H "Content-Type: application/octet-stream" \ --data-binary @module.luac ``` ```powershell # Windows, in PowerShell curl.exe https://api.unreadable.gg/v1/decompile ` -H "Content-Type: application/octet-stream" ` --data-binary "@module.luac" ``` ```python import pathlib, requests r = requests.post( "https://api.unreadable.gg/v1/decompile", headers={"Content-Type": "application/octet-stream"}, data=pathlib.Path("module.luac").read_bytes(), ) body = r.json() if not r.ok: raise SystemExit(f"{body['error']['code']}: {body['error']['message']}") pathlib.Path("module.luau").write_text(body["source"], encoding="utf-8") ``` ```js // Node 18 or later, saved as an .mjs file import { readFile, writeFile } from "node:fs/promises"; const r = await fetch("https://api.unreadable.gg/v1/decompile", { method: "POST", headers: { "Content-Type": "application/octet-stream" }, body: await readFile("module.luac"), }); const body = await r.json(); if (!r.ok) throw new Error(`${body.error.code}: ${body.error.message}`); await writeFile("module.luau", body.source); ``` ### with options To set [options](https://unreadable.gg/docs/api#options), send JSON instead: the file in base64 as `bytecode`, and your options in `options`. ```sh printf '{"bytecode":"%s","options":{"preset":"balanced"}}' "$(base64 < module.luac | tr -d '\n')" | curl https://api.unreadable.gg/v1/decompile \ -H "Content-Type: application/json" \ --data-binary @- ``` ```python import base64, pathlib, requests r = requests.post( "https://api.unreadable.gg/v1/decompile", json={ "bytecode": base64.b64encode(pathlib.Path("module.luac").read_bytes()).decode(), "options": {"preset": "balanced"}, }, ) ``` The API is strict. An unknown field, a query parameter or a bad value gets a [`400`](https://unreadable.gg/docs/api#errors) that names the field. ### progress Send `Accept: application/x-ndjson` and you get progress while it runs, one JSON object per line. Without it, you get one JSON body at the end. | | message | when | | --- | --- | --- | | → you send | `Accept: application/x-ndjson` | With the usual request. | | ← you get | `200` | Right away, before the decompile starts. | | ← you get | `{"stage":"decompiling"}` | Always first. | | ← you get | `{"stage":"renaming","done":0,"total":210}` | Only with `modelNames`: the naming model was asked. | | ← you get | `{"stage":"renaming","done":210,"total":210}` | Only with `modelNames`: it answered, once per piece of a large module. | | ← you get | `{"stage":"done",…}` or `{"stage":"error",…}` | Always last. `done` carries the [response](https://unreadable.gg/docs/api#response). An [error](https://unreadable.gg/docs/api#errors) can't change the status anymore, so it comes as this line, with `Retry-After` as `retryAfter`. | ### from a browser Only unreadable.gg can call the API from a web page. Call it from your server instead, or use the [stream](https://unreadable.gg/docs/stream) with a key. ## response A decompile that runs returns `200` and JSON, whatever the verdict. ```json { "source": "local function area(width: number, height) -- unreadable.gg ~ 12ms\n...", "verdict": "decompiled-unverified", "notes": [], "functions": [{ "id": 1, "name": "area", "line": 1, "endLine": 3, "parent": null, "inputLine": 1 }], "calls": [], "setting": { "preset": "faithful", "overrides": {}, "switches": { "freeLines": false, "...": false }, "spent": [], "implied": {}, "blocked": {} }, "version": "2026.10.16-90a500a" } ``` | field | what it is | | --- | --- | | `source` | The decompiled source. Line 1 ends with `-- unreadable.gg ~ 12ms`, how long it took (`~ 140ms (+3.2s naming)` with `modelNames`). It never moves a line. | | `verdict` | How much to trust the source. See [verdicts](https://unreadable.gg/docs#verdicts). | | `notes` | What was marked or refused, and where. See [notes](https://unreadable.gg/docs/api#notes). | | `functions` | Every function as `source` prints it: `id` (from 1, in the order they start in `source`), `name` (`null` when unnamed), `line` and `endLine` in `source`, `parent` (the enclosing function's `id`, or `null`), and `inputLine` (where it started in your original, if the file says). | | `calls` | Calls that provably reach a printed function (a local function called by name): `caller` (`null` for the main chunk), `callee` and `line`. | | `setting` | The `preset` and the switches you sent (`overrides`). `switches` has every switch's final value. `spent` lists what the switches cost (empty for `faithful`). `implied` and `blocked` list switches another switch turned on or kept off. | | `version` | The [decompiler version](https://unreadable.gg/docs/api#version), also in the `Decompiler-Version` header. | ## options | option | default | what it does | | --- | --- | --- | | preset | faithful | Sets every readability switch at once: `faithful`, `balanced` or `readable`. A switch you send overrides it. See [presets](https://unreadable.gg/docs/readability#presets). | | a switch | the preset's | Turns one [readability switch](https://unreadable.gg/docs/readability#switches) on or off by its API name, like `inferredTypes`. | | stockEnvironment | true | Assume builtins like `math` and `string` haven't been replaced. Turn it off if they might be. For now the source is the same either way. | | maxSeconds | 30, or 300 for a member | The time limit, 1 to 30 seconds (300 for a member), counted from when the decompiler starts. A plain JSON answer waits 60 seconds at most, so for longer, ask for [progress](https://unreadable.gg/docs/api#progress) or use the [stream](https://unreadable.gg/docs/stream#jobs). Set it lower to fail fast. JSON and stream jobs only. | `{"preset": "readable", "inferredTypes": false}` is `readable` with inferred types turned off. ## notes Each note explains one thing behind the verdict. If it's about a spot or a function, it's also in the source as a `-- unreadable:` comment. ```json { "code": "function-refused", "function": "label", "functionId": 2, "line": 6, "message": "this function was not decompiled, because it has a loop that can't be written as `while`, `repeat` or `for` yet; calling it raises an error", "detail": "proto 2 pc 14: …" } ``` - `function`: the name the source prints, like `v0`. `null` for the main chunk and unnamed functions. - `functionId`: the `functions` entry it's about. `null` for the main chunk or the whole file. - `line`: where the function starts in your original. `null` if the file has no line info. - `message`: what happened. - `remedy` (sometimes): what would fix it. The source prints it as `fix:`. - `detail` (sometimes): the decompiler's own wording, for bug reports. Not in the source. | code | verdict | what it means | | --- | --- | --- | | function-refused | refused | The function is a stub that errors when called. Its instructions are listed beside it. | | spot-marked | marked | This spot couldn't be printed exactly. `remedy`, if there is one, says what would fix it. | | decompiler-bug | marked | The decompiler caught its own mistake here. Please [send us the file](https://unreadable.gg/docs/api#support). | | spelling | unchanged | A value is written differently, like NaN as `0 / 0`. The program is the same. | | unverifiable | unchanged | The file numbers its opcodes differently from stock Luau, so the output can't be checked by recompiling it. | | names-partial | unchanged | With `modelNames`, the naming model named some values but not all. The rest keep their default names. | | names-unavailable | unchanged | With `modelNames`, the naming model didn't answer, so values keep their default names. | The last three are about the whole file, so they're not in the source. More codes may come later. If you get one you don't know, treat it like `spot-marked`: that part may not match your original. ## errors Errors are JSON with a `code`, a `message`, and the `field` at fault if there is one. They don't use up a free decompile, except `timed-out`. ```json { "error": { "code": "invalid-option", "message": "Option `inferredTypes` must be true or false.", "field": "options.inferredTypes" } } ``` For most `4xx`, fix the request. For `402`, wait for midnight UTC. For `429` and `503`, wait `Retry-After` seconds and try again. `500` is our fault. For `504`, see its row. | status | code | when | | --- | --- | --- | | 400 | invalid-json | The body isn't valid JSON. | | 400 | unknown-field | An unknown field or option, or a query parameter. | | 400 | invalid-option | An option isn't `true` or `false`, the `preset` is unknown, `maxSeconds` is out of range, or a key's `label` is over 64 characters. | | 400 | invalid-base64 | `bytecode` isn't valid base64. | | 400 | missing-bytecode | The body is empty, or the JSON has no `bytecode`. | | 400 | protocol | Stream only: a message it doesn't allow. It closes the stream. | | 401 | invalid-key | The [key](https://unreadable.gg/docs/api#accounts) is `malformed`, `unknown` or `revoked` (in `reason`). On the stream, `origin` means another site's page connected without a key, and `session` means your sign-in on the site ended. | | 401 | invalid-account | The account number is mistyped (`bad-character`, `wrong-length`, `wrong-check`) or `unknown`. | | 402 | out-of-credits | Today's [free decompiles](https://unreadable.gg/docs/api#free) are used up. They come back at midnight UTC, and retrying sooner fails. `membershipUrl` says where to get a membership. | | 403 | forbidden | The path isn't open to you: it's only for the site, or only for the people who run unreadable.gg. | | 404 | not-found | No endpoint at that path, or no key with that `id`. | | 405 | method-not-allowed | Wrong method. The `Allow` header lists the right ones. | | 409 | too-many-keys | The account already has 20 keys. | | 413 | too-large | The file is over the [size limit](https://unreadable.gg/docs/api#limits). | | 415 | unsupported-content-type | `Content-Type` is missing, or isn't `application/octet-stream` or `application/json`. | | 422 | not-luau-bytecode | Not Luau bytecode. The message says if it looks like Lua source or [another Lua version](https://unreadable.gg/versions). | | 422 | container | A `.rbxm` or `.rbxl` file. Open it on [the site](https://unreadable.gg) instead: it reads the file in your browser and only sends the scripts. | | 422 | unsupported-version | A Luau bytecode version the decompiler can't read yet. | | 422 | damaged-bytecode | The bytecode is cut short or doesn't hold together. | | 426 | upgrade-required | A [stream](https://unreadable.gg/docs/stream) request that isn't a WebSocket upgrade. | | 426 | unsupported-protocol | Stream only: `hello` asked for a protocol version the server doesn't speak. `protocols` lists the ones it does. | | 429 | rate-limited | A [limit](https://unreadable.gg/docs/api#limits) was hit. Wait `Retry-After` seconds. | | 429 | too-many-at-once | Too many decompiles running at once, or a member's key is in use from too many networks right now or today. Wait `Retry-After` seconds. | | 500 | internal | Something failed on our side. If it keeps happening, [tell us](https://unreadable.gg/docs/api#support). | | 503 | unavailable | The decompiler is busy, or today's free capacity is used up. Wait `Retry-After` seconds. | | 504 | timed-out | The decompile ran out of time. If you set a low `maxSeconds`, raise it. Members whose plain JSON answer stopped at 60 seconds can ask for [progress](https://unreadable.gg/docs/api#progress) to wait longer. Otherwise it'll likely time out again, so [send us the file](https://unreadable.gg/docs/api#support) instead of retrying. | ## limits | limit | free | member | | --- | --- | --- | | file size | 2 MiB | 8 MiB | | time per decompile | 30 s | 300 s | | requests | 120 a minute | no limit | | at once | 16 | 2048 | | decompile time | 5 minutes a day | 10 minutes, 30 minutes or 200 minutes a day, by [tier](https://unreadable.gg/docs/api#accounts) | | networks using the account | not counted | 2 at once, 5 a day | | open streams | 16 | 16 | Free limits count per connection: one IP address (with IPv6, one /64 range). Member limits count per account, across all its keys. Daily limits reset at midnight UTC. Decompile time is how long your decompiles take on our servers. Naming counts too, with `modelNames`. A network is your internet provider, VPN or mobile carrier, so a new address on the same one doesn't count again. A network stays in use for 2 minutes after its last decompile, or as long as a stream from it is open. Open streams are never cut off by the network limit. The size limit is on the file, so a base64 JSON body can be about a third bigger. Very large files can run out of memory and fail with `500`. ## free tier You get 100 free decompiles a day, shared by the site, the executor and the API. The Discord bot has the same limit, counted per Discord account. Errors and refusals don't count, except `timed-out`. Closing a request mid-decompile still counts. Most responses have a `Free-Remaining` header with how many you have left today. Members get it too, but their own decompiles don't lower it. ```http Free-Remaining: 3 ``` Requests turned away before decompiling, like a wrong key (`401`), a file that's too big (`413`) or a wrong `Content-Type` (`415`), don't have it. ## keys and accounts A membership lifts the free limits, takes files up to 8 MiB and gives each decompile up to 300 seconds. Its tier sets your decompile time a day. Every other [limit](https://unreadable.gg/docs/api#limits) is the same for all tiers. | tier | per 30 days | decompile time a day | scripts a day | | --- | --- | --- | --- | | Pro | $5 | 10 minutes | about 12,000 | | Max | $10 | 30 minutes | about 36,000 | | Ultra | $30 | 200 minutes | about 240,000 | Scripts a day is an estimate at 50 ms a script. Big scripts take longer. To get a membership, check out #purchase in [our Discord server](https://discord.gg/8gXdq43sK8). Make an account and its keys on the [account page](https://unreadable.gg/account), then send a key with each request: ```sh curl https://api.unreadable.gg/v1/decompile \ -H "Authorization: Bearer ugk_..." \ -H "Content-Type: application/octet-stream" \ --data-binary @module.luac ``` Every member response has a `Member-Until` header that says when the membership ends. After that, the key falls back to the free tier. A wrong key always gets `401`, never the free tier. Programs can manage the account too. The account number (`UG-XXXX-XXXX-XXXX-XXXX-C`) is the sign-in, so it goes in the JSON body, never the URL: | request | what it does | | --- | --- | | `POST /v1/accounts` | Makes an account. Returns the account number and a first key. Ten a day per connection. | | `POST /v1/keys` | Makes a key for `{"account": "UG-…", "label": "laptop"}`. Up to 20 keys an account, labels up to 64 characters. | | `GET /v1/account` | With a key, returns `paidUntil`, `membershipUrl`, how many keys the account has and, for a member, `tier`: its `id`, `name`, `usd`, `dailyMs` (decompile time a day) and `scriptsPerDay`. | | `DELETE /v1/keys` | With a key, revokes that key. Answers `204`. | | `POST /v1/keys/list` | For `{"account": "UG-…"}`, returns each key's `id`, `label`, `made` and `lastUsed`. | | `POST /v1/keys/revoke` | For `{"account": "UG-…", "id": "…"}`, revokes that key. Answers `204`. | We show the account number and each key once and only store a hash, so save them somewhere safe. Twenty wrong account numbers in a day gets a connection a `429`. Accounts that never had a membership are deleted after 90 days. Ones that did are deleted a year after the membership ends or a key was last used, whichever is later. ## decompiler version Every response names the decompiler version in `version` and in this header: ```http Decompiler-Version: 2026.10.11-4227ea3 ``` The same file, options and version always give the same source and notes. The only exceptions are the time on line 1 and, with `modelNames`, the names it picks. The [changelog](https://unreadable.gg/changelog) lists what changed. ## support If a decompile runs differently from your original, or you find a bug, open a #support ticket in [our Discord server](https://discord.gg/8gXdq43sK8) or mail [support@unreadable.gg](mailto:support@unreadable.gg). Send the file, the options and the version. We fix wrong programs first. --- # Readability presets and switches Presets and switches change how the code reads, never what it does. Some of them cost something you might need, like matching line numbers. ## which preset Use `balanced` if you don't want to think about it. Use `faithful` if you want source that compiles to the same bytes, or stack traces that point into the right function. Just know it can be quite verbose. Use `readable` if you want the cleanest result. It turns every switch on, so names, types and line numbers can differ from the original. The site, the executor and the Discord bot start on `balanced`. The API and the MCP server start on `faithful`. ## presets A preset sets every switch at once. | switch | faithful (exact bytes) | balanced (easy to read) | readable (everything on) | | --- | --- | --- | --- | | Readable layout | · | ● | ● | | Function line numbers | · | ● | ● | | Inferred types | · | ● | ● | | Fold constants | · | · | ● | | Model names | · | ● | ● | | Drop dead code | · | · | ● | | Upvalue notes | · | ● | ● | | Upvalue notes on own line | · | ● | ● | | Compound assignment | · | ● | ● | | Positive conditions | · | ● | ● | | Early returns | · | ● | ● | | Paragraph breaks | · | ● | ● | | Inline small calls | · | · | ● | | Roll up loops | · | ● | ● | | Math constants | · | · | ● | | Const locals | · | ● | ● | | Field functions | · | ● | ● | | Mutable globals | · | ● | ● | Want `readable` without inferred types? Pick `readable` and turn that one switch off. On the site, it's under options. Over the API, send `{"preset": "readable", "inferredTypes": false}`. ## what a switch costs | cost | what changes | | --- | --- | | bytes | The source no longer compiles to the same bytecode. | | lines | Code moves off its original lines, so line numbers in errors no longer match. | | names | Values get names the file didn't have, or lose names it did. | | types | Type annotations appear that the file didn't have. | | frames | A call to a small helper becomes the helper's body, so stack traces have one frame fewer. | | version | The source uses syntax only newer Luau understands, like `const`, so older tools reject it. | The API lists what a result's switches cost in `setting.spent`. `faithful` costs nothing. ## faithful layout Faithful keeps every function on its original line, so you'll see runs of blank lines. Turn on Readable layout (`freeLines`) to get rid of them. ## switches Every switch is off in `faithful`. Each example shows the same bytecode decompiled without the switch, then with it. ### Readable layout (`freeLines`) Lays functions out as reads best, not padded or packed onto their original lines. On in `balanced` and `readable`. Costs: [bytes, lines](https://unreadable.gg/docs/readability#costs). Without: ```lua print(1) print(2) function done() print(3) end done() ``` With: ```lua print(1) print(2) function done() print(3) end done() ``` ### Function line numbers (`lineNotes`) Under the readable layout, ends each function's first line in the line it was defined on. On in `balanced` and `readable`. Without: ```lua print(1) print(2) function done() print(3) end done() ``` With: ```lua print(1) print(2) function done() -- line 2 print(3) end done() ``` ### Inferred types (`inferredTypes`) Adds the type annotations inference concludes, beside the input's own. On in `balanced` and `readable`. Costs: [bytes, types](https://unreadable.gg/docs/readability#costs). Without: ```lua local function label(n) return "item " .. tostring(n) end return label(3) ``` With: ```lua local function label(n: number): string return "item " .. tostring(n) end return label(3) ``` ### Fold constants (`foldConstants`) Writes a computation on literals as its value: `7` for `math.max(4, 7)`. On in `readable`. Costs: [bytes](https://unreadable.gg/docs/readability#costs). Without: ```lua local width = math.max(4, 7) print(width) ``` With: ```lua local width = 7 print(width) ``` ### Model names (`modelNames`) Names the values the input left unnamed, never with a name that could change the program. On in `balanced` and `readable`. Costs: [names](https://unreadable.gg/docs/readability#costs). Without: ```lua local function v0(arg0) local v1 = 0 for i = 1, #arg0 do v1 = v1 + arg0[i] end return v1 end return v0({ 1, 2, 3 }) ``` With: ```lua local function sum(values) local sum1 = 0 for i = 1, #values do sum1 = sum1 + values[i] end return sum1 end return sum({ 1, 2, 3 }) ``` ### Drop dead code (`dropDeadCode`) Leaves out values nothing reads and branches that never run. On in `readable`. Costs: [bytes, names](https://unreadable.gg/docs/readability#costs). Without: ```lua local unused = 21 print("ready") ``` With: ```lua print("ready") ``` ### Upvalue notes (`upvalueNotes`) Says on each function's first line which outer variables it captures, and whether it sees their later changes. On in `balanced` and `readable`. Without: ```lua local function counter(k) local n = 0 return function() n += k return n end end print(counter(2)()) ``` With: ```lua local function counter(k) local n = 0 return function() -- upvalues: n (ref), k (copy) n += k return n end end print(counter(2)()) ``` ### Upvalue notes on own line (`upvalueNotesOwnLine`) Puts the upvalue note on a line of its own under the function's first line. On in `balanced` and `readable`. Costs: [lines](https://unreadable.gg/docs/readability#costs). Without: ```lua local function counter(k) local n = 0 return function() -- upvalues: n (ref), k (copy) n += k return n end end print(counter(2)()) ``` With: ```lua local function counter(k) local n = 0 return function() -- upvalues: n (ref), k (copy) n += k return n end end print(counter(2)()) ``` ### Compound assignment (`compoundEverywhere`) Writes `x op= e` wherever it means the same thing. On in `balanced` and `readable`. Costs: [bytes](https://unreadable.gg/docs/readability#costs). Without: ```lua local t = { k = 1 } t.k = t.k + 2 print(t.k) ``` With: ```lua local t = { k = 1 } t.k += 2 print(t.k) ``` ### Positive conditions (`positiveBranches`) Tests an `if` without `not`, and puts a short exit arm first. On in `balanced` and `readable`. Costs: [bytes, lines](https://unreadable.gg/docs/readability#costs). Without: ```lua local function check(ok) if not ok then print("no") else print("yes") end end check(true) ``` With: ```lua local function check(ok) if ok then print("yes") else print("no") end end check(true) ``` ### Early returns (`earlyExits`) A function that ends in one big `if` returns early instead. On in `balanced` and `readable`. Costs: [bytes](https://unreadable.gg/docs/readability#costs). Without: ```lua local function save(s) if s then print(s) print(#s) end end save("x") ``` With: ```lua local function save(s) if not s then return end print(s) print(#s) end save("x") ``` ### Paragraph breaks (`paragraphBreaks`) Adds a blank line between the steps of the code. On in `balanced` and `readable`. Costs: [bytes, lines](https://unreadable.gg/docs/readability#costs). Without: ```lua local squares = {} for i = 1, 3 do squares[i] = i * i end return squares ``` With: ```lua local squares = {} for i = 1, 3 do squares[i] = i * i end return squares ``` ### Inline small calls (`inlineCalls`) Writes a call of a small local helper as the helper's body. On in `readable`. Costs: [bytes, names, frames](https://unreadable.gg/docs/readability#costs). Without: ```lua local function key(name, n) return name .. "_" .. n end local k = key("hp", 3) print(k) ``` With: ```lua local k = "hp" .. "_" .. 3 print(k) ``` ### Roll up loops (`rerollLoops`) Writes lines repeated with 1, 2, 3 in them as the `for` loop that runs them. On in `balanced` and `readable`. Costs: [bytes](https://unreadable.gg/docs/readability#costs). Without: ```lua local t = { 5, 6, 7 } print(t[1]) print(t[2]) print(t[3]) ``` With: ```lua local t = { 5, 6, 7 } for i = 1, 3 do print(t[i]) end ``` ### Math constants (`unfoldMath`) Writes a number the `math` library computes as that expression: `math.pi / 2` for `1.5707963267948966`. On in `readable`. Costs: [bytes](https://unreadable.gg/docs/readability#costs). Without: ```lua local quarter = 1.5707963267948966 print(math.sin(quarter)) ``` With: ```lua local quarter = math.pi / 2 print(math.sin(quarter)) ``` ### Const locals (`constLocals`) Declares a local nothing assigns again as `const`. On in `balanced` and `readable`. Costs: [version](https://unreadable.gg/docs/readability#costs). Without: ```lua local limit = tonumber("8") local count = 0 count = count + (limit :: any) print(count) ``` With: ```lua const limit = tonumber("8") local count = 0 count = count + (limit :: any) print(count) ``` ### Field functions (`fieldFunctions`) Writes a table of keyed functions as `local t = {}` and a `function t.k()` per function. On in `balanced` and `readable`. Costs: [bytes, names](https://unreadable.gg/docs/readability#costs). Without: ```lua return { ["clamp"] = function(arg0) return math.max(0, math.min(arg0, 1)) end, ["twice"] = function(arg0) return arg0 * 2 end } ``` With: ```lua local module = {} function module.clamp(arg0) return math.max(0, math.min(arg0, 1)) end function module.twice(arg0) return arg0 * 2 end return module ``` ### Mutable globals (`mutableGlobals`) Writes `game.Players` for `(game).Players`, as scripts compiled with those globals mutable read them. On in `balanced` and `readable`. Costs: [bytes](https://unreadable.gg/docs/readability#costs). Without: ```lua local camera = (workspace).CurrentCamera print(camera) ``` With: ```lua local camera = workspace.CurrentCamera print(camera) ``` --- # unreadable.gg stream `GET wss://api.unreadable.gg/v1/stream` Run lots of decompiles over one WebSocket. Each job is a [JSON request](https://unreadable.gg/docs/api#request) with an id, and you get back the same [response](https://unreadable.gg/docs/api#response) or [error](https://unreadable.gg/docs/api#errors) as over HTTP. It shares your [limits](https://unreadable.gg/docs/api#limits) and free decompiles with HTTP and the site. ## executor client Run this in your executor to get a `decompile` that uses the stream: ```lua shared.UNREADABLE_KEY = "ugk_..." -- optional: decompile as your account loadstring(game:HttpGet("https://unreadable.gg/decompile.lua"))() ``` It replaces your executor's `decompile`. `decompile(script)` waits for the source and returns it as a string. You can pass bytecode as a string too. Your executor needs `WebSocket.connect` and `getscriptbytecode`. Without `WebSocket.connect`, every call returns an `unsupported-executor` comment. It never errors. If something fails, you get a comment instead of the source: ```lua -- unreadable.gg could not decompile this script -- out-of-credits: … ``` A script with no bytecode returns `-- unreadable.gg: this script has no bytecode`. All calls share one connection, and the same bytecode is only decompiled once. ### options Set `shared.UNREADABLE_OPTIONS` before the `loadstring` line to pick [options](https://unreadable.gg/docs/api#options). For example, `readable` without `modelNames`. ```lua shared.UNREADABLE_OPTIONS = { preset = "readable", modelNames = false } ``` The default is `{ preset = "balanced" }`. It turns on `modelNames`: better names, but slower, and it uses more decompile time. `{}` gives you the server's default, `faithful`. ### key Put a key from the [account page](https://unreadable.gg/account) in `shared.UNREADABLE_KEY` to decompile as your account. Without a membership, you get 100 free decompiles a day. After that, `decompile` returns an `out-of-credits` comment until midnight UTC. ## writing a client [decompile.lua](https://unreadable.gg/decompile.lua) is a complete client. Use it as a reference. ## connecting Open the WebSocket with no query string and no `Authorization` header. If the server refuses the upgrade, you get a normal [error](https://unreadable.gg/docs/api#errors) body. Every message is one JSON object in a text frame, except binary jobs. Send `hello` within 10 seconds: ```json {"type":"hello","protocol":1,"key":"ugk_..."} ``` The `key` is optional. Without it, you use your connection's free decompiles, like the site. A page on another site needs a key. A wrong key closes the stream with `invalid-key`. The server answers `ready`: ```json {"type":"ready","protocol":1,"version":"2026.10.21-926ecd4","auth":"anonymous","member":false,"memberUntil":null,"freeRemaining":97,"limits":{"inFlight":16,"perMinute":120,"maxFileBytes":2097152,"maxSeconds":30}} ``` - `auth`: who the stream runs as. `key`, `anonymous`, or `session` if you're signed in on the site. - `limits.inFlight`: how many jobs can run at once, across all your streams and HTTP requests. - `perMinute` and `freeRemaining`: `null` for members. A member's `ready` also has `tier`, like [`GET /v1/account`](https://unreadable.gg/docs/api#accounts). - `maxFileBytes`: your file limit in bytes. 2 MiB free, 8 MiB for members. You get a fresh `ready` when a membership starts or ends, when limits change, and at midnight UTC. ## jobs ```json {"type":"job","id":"17","options":{"preset":"readable"},"bytecode":"DQMMBG1hdGgDYWJz…"} ``` `bytecode` is standard base64 with padding. You pick the `id`: 1 to 128 characters from `!` to `~`, without `"` or `\`. Keep ids unique for the whole run, across reconnects. A counter works. A binary frame is a quarter smaller. It holds these fields in order: | bytes | field | | --- | --- | | 3 | `UGJ` | | 1 | format, `1` | | 1 | the id's length, L | | L | the id | | 2 | the options' length H, big-endian, up to 4096, or 0 | | H | the options, as UTF-8 JSON | | the rest | the file | Each job gets stage lines, then one final line: `done` or `error`. Jobs run side by side and finish in any order: ```json {"id":"17","stage":"decompiling"} {"id":"17","stage":"renaming","done":0,"total":210} {"id":"17","stage":"done","source":"local Net = {}\n…","verdict":"decompiled-unverified","notes":[],"setting":{…},"version":"2026.10.21-926ecd4","freeRemaining":96} {"id":"19","stage":"error","error":{"code":"rate-limited","message":"More than 120 decompiles this minute from this connection."},"retryAfter":12} ``` The final line has what HTTP sends as headers: `freeRemaining`, `memberUntil` and `retryAfter`. A `done` over 1 MiB is split. `source` lines come first, in order, each with a piece of `text`. Then a `done` arrives whose `parts` says how many pieces to join. Skip any stage you don't know. An unknown field in a job only fails that job, with `unknown-field`. ### flow control The server never queues. Keep up to `limits.inFlight` jobs running, largest first. A job over a limit is refused right away with `retryAfter`. Wait that long, then resend it with a new id. Don't hammer it. Jobs refused right away (before any stage line) with `rate-limited`, `too-many-at-once` or `unavailable` add up. More than 120 in a minute (1000 for members) closes the stream with `rate-limited` and a `retryAfter` of 60. One stream slows down past a few hundred large jobs at once. For big runs, open more streams as the load grows, and keep `limits.inFlight` as the total across all of them. The site and the executor client open another stream every 256 jobs, up to 8 for a member, and close extras after 10 idle seconds. You can have 16 streams open. Past that, a new one is refused with `too-many-at-once`. Wait its `retryAfter`, then try again. A job uses a free decompile once it starts, even if the stream closes first. ## errors Send `{"type":"ping"}` any time and the server answers `pong`. While jobs run, the server pings you after 30 quiet seconds, and you answer `{"type":"pong"}`. After 60 quiet seconds, the stream closes with `timed-out`. An idle stream closes after 10 minutes. Before closing, the server sends one `{"type":"error",…}` message, then closes with code 4000 plus the status, like 4429. These close it with `protocol`: - a text message that isn't one JSON object - an unknown `type` - an unknown field in anything but a job - anything but a job over 4096 bytes - a bad job `id` - a job whose `id` is still running - a job before `ready` - a second `hello` - no `hello` within 10 seconds | close reason | what to do | | --- | --- | | `protocol`, `invalid-key`, `unsupported-protocol` | Fix your client. Don't reconnect. | | `rate-limited`, `too-many-at-once`, `unavailable` | Reconnect after `retryAfter` seconds. | | anything else, or no error | Reconnect with backoff. | ## reconnecting The server forgets a closed stream. Resend every job without a final line, with a new id. Also resend jobs that were told to wait: `too-many-at-once`, `rate-limited` and `unavailable` after their `retryAfter`, and `out-of-credits` after midnight UTC. Back off 1, 2, 4, 8, 16, then 30 seconds, with a bit of randomness, or wait `retryAfter`. Then send `hello` again and use the new `ready`'s limits. Old jobs may still be running, so expect some `too-many-at-once` at first. A stream lasts through one midnight UTC. At the second, it refuses new jobs with `unavailable` and closes once its jobs finish. Reconnect and resend. ## protocol versions Protocol 1 may get new fields, messages, stages, codes and options, so skip what you don't know. Anything a client has to send or understand only changes in a new protocol. --- # unreadable.gg for agents Agents can use the same API as any other program. There's also an MCP server, and every page has a markdown version. ## pages for agents [llms.txt](https://unreadable.gg/llms.txt) lists these docs. [llms-full.txt](https://unreadable.gg/llms-full.txt) has every page in one file. Every page is also markdown. Add `.md` to its address, like [`/docs/api.md`](https://unreadable.gg/docs/api.md), or send `Accept: text/markdown`. If your agent can run commands, use curl, like in the [quickstart](https://unreadable.gg/docs#quickstart). The file goes straight from disk and never enters the model's context. Add `-o result.json` and only read the fields you need, like `verdict` and `notes`. The full schema is at [`https://api.unreadable.gg/v1/openapi.json`](https://api.unreadable.gg/v1/openapi.json), with every option, note code and error code. ## mcp `POST https://api.unreadable.gg/mcp` The MCP server is stateless and only speaks revision `2026-07-28`. There's no `initialize` and no session. A client that starts with `initialize` (the older handshake) gets an "unsupported protocol version" error, so your client has to support the stateless revision. Each request carries the protocol version and client capabilities in `_meta`. Its `MCP-Protocol-Version`, `Mcp-Method` and `Mcp-Name` headers must match the body. It answers `server/discover`, `tools/list`, `tools/call` and `subscriptions/listen`. Without a key, calls use your connection's free decompiles. To use a member's account, send `Authorization: Bearer ugk_...` with a key from the [account page](https://unreadable.gg/account), same as the [API](https://unreadable.gg/docs/api#accounts). | tool | what it does | | --- | --- | | decompile | Decompiles one module from base64 `bytecode`. Returns a handle, the verdict, line and function counts, and notes by code. Includes the source if it's 120 lines or less. Takes the same [options](https://unreadable.gg/docs/api#options) as the API. Uses one free decompile. | | decompile_many | Decompiles up to 16 named modules with one set of options. Returns a handle and summary for each, or its error. The same bytes under two names are decompiled once. Each unique module uses one free decompile. For more, or a whole `.rbxl`/`.rbxm`, use [the site](https://unreadable.gg) or the [stream](https://unreadable.gg/docs/stream). | | list_functions | Every function in source order: id, name, first and last line, parent, and note count. | | get_function | One function's source and notes, by id or name, with the functions it calls and is called by. | | get_source | The whole source, or a range of lines. To change the code, patch and recompile the whole source. | | search_source | The lines that contain a text or match a pattern, each with the function it's in. | | list_notes | The [notes](https://unreadable.gg/docs/api#notes), or only the ones with one `code`. | | compare | What changed between two handles: functions added, removed and changed, matched by name, with a diff of each changed one. Free. | | explain_switch | What one [switch](https://unreadable.gg/docs/readability#switches) or preset does. No handle needed. Free. | Each note says which function it's about. `get_function` only lists calls to local functions by name, since those are the ones the decompiler can prove. Calls through tables or globals aren't listed. Pick the preset by what the source is for: | preset | for | | --- | --- | | faithful | Auditing, patching and recompiling, or matching a crash's line numbers. | | balanced | Understanding the code. | | readable | Skimming. | ## handles To keep the file out of the model's context, send it from disk to `/v1/handles`, then read the result with the tools: ```sh curl https://api.unreadable.gg/v1/handles \ -H "Content-Type: application/octet-stream" \ --data-binary @module.luac ``` It takes the same request and key as `/v1/decompile`, and answers like the `decompile` tool. A handle works for one hour. The result is encrypted with a key that only exists in the handle, so nobody can read it without the handle, us included ([privacy](https://unreadable.gg/privacy#agents)). If a tool returns `handle-not-ready` in the first two minutes, call it again after `retryAfter` seconds. To send up to 16 modules at once, use `multipart/form-data`. Make each module a file part, named by its filename. Add an optional `options` part with the options as JSON. The whole body is at most 8 MiB, and it answers like `decompile_many`: ```sh curl https://api.unreadable.gg/v1/handles \ -F file=@a.luac -F file=@b.luac \ -F 'options={"preset":"balanced"}' ``` Each file is held to your [file size limit](https://unreadable.gg/docs/api#limits): 2 MiB free, 8 MiB for members. --- # Luau decompiler We turn Luau bytecode back into source. Where we can't be sure of something, we mark the spot or refuse that function instead of guessing. ## what it reads | you have | what happens | | --- | --- | | Luau bytecode | These files are compiled modules from the standard Luau compiler in bytecode versions 1 to 14, at any optimisation and debug level. | | older formats | The decompiler removes the RSB1 compressed wrapper before reading the module, and handles the pre-release version 1 format. | | `.rbxl` and `.rbxm` files | You open them in your browser, binary or XML (`.rbxlx`, `.rbxmx`). Only their scripts' bytecode gets sent, and you download a copy with the decompiled scripts. | In an executor, the [executor client](https://unreadable.gg/docs/stream#client) gets each script's bytecode for you. Other Lua versions aren't read yet: see [lua versions](https://unreadable.gg/versions). ## what you get back Here are the instructions for one function, as the bytecode holds them: ``` ; function onLeave(player) GETUPVAL R1 0 LOADNIL R2 SETTABLE R2 R1 R0 RETURN R0 1 ``` Here is the source the decompiler gives back for it, which compiles to those same instructions: ```lua local function onLeave(player) sessions[player] = nil end ``` Names come from the module's debug information where it kept them. Where it didn't, the default output uses numbered names such as `v0` for locals and `arg0` for parameters, or a name taken from what it certainly holds. It never invents a name that sounds right but might mislead you. The Model names switch (`modelNames`), on in the `balanced` and `readable` [presets](https://unreadable.gg/docs/readability) and on the site by default, asks a model to name the rest: easier to read, but those names are its guesses about what values mean, not facts from the file. They never change what the program does. ## how sure it is Every change to the decompiler is tested on hundreds of Luau modules, each compiled at every optimisation level. Nearly all must decompile to source that compiles back to identical bytecode, and every one must run the same as the original. Your own file isn't recompiled or run when you decompile it, so every result comes with a [verdict](https://unreadable.gg/docs#verdicts) on how far to trust it, and points at any spot that was marked or function that was refused. What changed recently is on the [changelog](https://unreadable.gg/changelog). Today's Luau compiler can't write bytecode versions 1 and 2 or the pre-release format, so source decompiled from them can never compile back to the same bytes. For those, the decompiler aims for the same program. ## try it Drop a compiled module or a `.rbxl`/`.rbxm` on the home page. You get 100 free decompiles a day. Scripts and agents can use the same decompiler through the [API](https://unreadable.gg/docs). [decompile a file](https://unreadable.gg/) --- # Lua versions Each Lua version compiles to its own bytecode, and the decompiler learns them one at a time, only once it can decompile that version as faithfully as the last. Today that's Luau. | version | today | | --- | --- | | [Luau](https://unreadable.gg/luau) | decompiles | | Lua 5.5 | not yet | | Lua 5.4 | not yet | | Lua 5.3 | not yet | | Lua 5.2 | not yet | | Lua 5.1 | not yet | | LuaJIT | not yet | ## dropping one that isn't read yet You get a [`not-luau-bytecode`](https://unreadable.gg/docs/api#errors) error, which says which Lua the file looks like when it can tell, and costs nothing. The decompiler doesn't try to read it as something close, because a decompile from a misread file would be a different program that looks right. When another version is added, it'll be in a release on the [changelog](https://unreadable.gg/changelog). --- # unreadable.gg changelog Every release of the decompiler, newest first. Each is named by its [version](https://unreadable.gg/docs/api#version), the one the API sends with every result, so you can look up what changed in the version that decompiled your file. ## 2026.11.09-62f1c30 · fewer placeholders, plainer markers and new readability switches - Far fewer functions come back as placeholders. Code where both arms of an `if` end in the same place, `continue` in a `repeat` with an `until` test, `pcall(fn, ...)` inside a loop that returns, and returns from deep inside loops now decompile. - Fixed: a `repeat` returning a power could print `-0` where the original printed `0`. - Fixed: `return x or (if a then b else c)` inside a `repeat` could read a name that was never defined. - Fixed: an `elseif` could test `false` because a temporary reused a parameter's slot. - Fixed: some locals and captured values were read as globals, in `repeat ... until` tests, unused functions and closures. - Fixed: in functions with more than 255 constants, some divisions and `or`s used the wrong constant. - Fixed: some closures the original shared were created twice, and `inline-calls` could inline a helper that was only set on one path, calling `nil`. - Markers are shorter and plainer: one banner at the top, a `fix:` line where there's a fix, and no shouting. - New switch `const-locals`: a local that's never assigned again prints as `const` (Luau 0.727 or later parses it). - New switch `field-functions`: a table of keyed functions prints as `local t = {}` and `function t.k()`. - New switch `line-notes`: under the readable layout, each function's first line ends in `-- line N`, where it was defined. - New switch `mutable-globals`: `game.Players` and `workspace.CurrentCamera` print without the parentheses. - `for _, v in keys, nil, nil do` prints as `for _, v in keys do`. - Lines match the original more often, including in files with no final newline. - More `.rbxl`/`.rbxm` files open: scripts whose bytecode is stored as text, and `.rbxlx`/`.rbxmx` files with a stray `]]>` in a script, which used to be refused whole. - `modelNames` now names large scripts too. - Memberships come in three tiers, Pro, Max and Ultra, each with its own daily decompile time. Members get up to 300 seconds a decompile, and a key works from 2 networks at once and 5 a day. - The [Discord bot](https://unreadable.gg/docs#discord) decompiles with `/decompile`, with the same free limit, and `/status` shows how many you have left. - The [account page](https://unreadable.gg/account) shows today's usage as a bar. Memberships are bought in our Discord server. - The docs are shorter and plainer. ## 2026.10.29-c2107ad · xml place files - You can now drop `.rbxlx` and `.rbxmx` files on the site, just like `.rbxl` and `.rbxm`. - Fewer functions are left as stubs: many scripts that had a refused function now decompile in full. - Optimised scripts decompile more often, especially ones that call small helper functions. - Loops that always return, or can never run, no longer stop the code around them from decompiling. - Fewer spots are marked as uncertain. ## 2026.10.29-d0af3ea · more scripts decompile, and new tools for agents - More code now decompiles fully instead of being left as a placeholder. This includes `if`/`else` expressions, tables built from function calls (`{ f(), k = g() }`), `return f()` inside a loop, `if a or f() then`, early returns inside nested `if`s, `break` inside nested `repeat` loops, and closures that use a local changed later. - Fixed: some `for` loops in scripts with debug info came back as a placeholder that threw an error the original never threw. - Fixed: in some `-O2` scripts with debug info, a variable used in a `repeat ... until` condition was read as a global instead. - Fixed: some `-O2` scripts with debug info failed to decompile at all. - More `for` loops in scripts with debug info now print as loops instead of placeholders. - `function f() end`, where `f` is already a local, no longer creates a second local named `f`. - Lines match the original more often in scripts with debug info. - Tables of tables now print one row per line. - Agents get new tools: `search_source` to search the output, `compare` to diff two results function by function, and `decompile_many` for up to 16 scripts at once. `get_function` finds a function by name and shows what it calls and what calls it. Small scripts (120 lines or fewer) come back with their source right away. - MCP clients now ask before running `decompile`, since each call uses one of your free decompiles. - `/v1/decompile` now also tells you where each function starts and ends, which functions call which, and which function each note belongs to. ## 2026.10.27-0719266 · place files without false time-outs - A script that only waited in line behind others is no longer refused as having run past its time limit: it is answered "busy" and the site tries it again. `maxSeconds` now counts from when the decompiler starts on a job. - Model names no longer hold a decompile open while the names are chosen, so large `.rbxm` and `.rbxl` files run with fewer refusals. - Fewer "overloaded" errors when many jobs run at once: a retry now waits a moment before it goes again. - Free decompiles no longer run out early in the day. - Members may send files up to 8 MiB, and get 4 hours of decompile time a day, which now includes the naming model's time. Big place files start faster for members. - Fixed at `-O0` and `-O1`: a local whose only later write was never read printed inline, which could let a weak table drop its key. - Fixed at `-O2` with full debug info: a function whose calls were inlined could come back with more locals than the compiler accepts. - With full debug info, output keeps more of the original: `t[8]` stays `t[8]` when a constant local happens to hold 8, an inlined call's parameters print as the caller's locals (`local a = t[1]`), a numeric `for` keeps its index name when its start is an upvalue, and a one-line loop keeps its `end` on that line. - Older bytecode (version 2): a generic `for` over an iterator the decompiler can't prove now decompiles instead of standing in as a stub. - At `-O0`, a `for` that returns from inside a `while` under `repeat ... until true` prints as a plain loop instead of a marked one. - At `-O2`, a chain of more than five inlined functions decompiles to the same program. ## 2026.10.26-554835d · 2048 decompiles at once - Members can now really run up to 2048 decompiles at once. A big run, such as a whole place file, is spread over several connections, so it finishes in well under a minute instead of stalling. - The site and the [client for executors](https://unreadable.gg/docs/stream#client) open more connections as a run grows, and close them again once it's done. - A decompile no longer fails because a heavy one was running next to it. When that happens, it's tried again before you see an error. - Model names no longer hold up other decompiles: names are added after the decompile itself has finished. - The place file panel no longer shows a "waiting" countdown or a "starting the naming model" message. ## 2026.10.26-537cd9b · accounts, place files and the stream - Accounts: make one on the new [account page](https://unreadable.gg/account), with no email or password. The account's number is how you sign in, so keep it somewhere safe. On the same page you make and revoke keys for your programs, which send them as `Authorization: Bearer ugk_…`. See [keys and accounts](https://unreadable.gg/docs/api#accounts). - A member's decompiles don't count against the free tier. Up to 2048 run at once, with no limit a minute, and each account gets an hour of decompile time a day. Every response says when the membership ends, in `Member-Until`. To get a membership for now, mail [support@unreadable.gg](mailto:support@unreadable.gg). - Place and model files: drop a `.rbxl` or `.rbxm` on the site. It opens in your browser, only the scripts' bytecode is sent, and you get the file back with every script's source filled in. Sent to the API, a place file gets `422 container`. - Many decompiles over one connection: the [stream](https://unreadable.gg/docs/stream) is a WebSocket you send jobs on, with a [client for executors](https://unreadable.gg/docs/stream#client). - Older files: the decompiler reads bytecode versions 1 and 2, early pre-release files and RSB1-wrapped files. These can't recompile to the same bytes, so the output aims for the same program. - Free tier: 16 decompiles at once (was 2), 120 a minute (was 60) and 5 minutes of decompile time a day. The new `maxSeconds` option (1 to 30) makes a decompile give up sooner. A decompile that times out now uses a free decompile. See [limits](https://unreadable.gg/docs/api#limits). - The API reads no query strings at all: options and fields go in a JSON body, and any query string gets `400 unknown-field`. - Closing a request while it decompiles now stops the decompile. - The viewer: the bytecode pane is gone, so the source takes the full width. The presets sit on the bar, and the switches open in a drawer beside the code. On the home page, "options" sets them before you drop a file, and when you're signed in your choice is kept for next time. - The docs are now five short pages: an [overview](https://unreadable.gg/docs), the [API](https://unreadable.gg/docs/api), [readability](https://unreadable.gg/docs/readability), the [stream](https://unreadable.gg/docs/stream) and [agents](https://unreadable.gg/docs/agents). Old links still land in the right place. - Model names arrive sooner, with no wait for the model to start. Output over 200,000 characters keeps its default names. A function stored on a table that uses the table as `self` prints as `function T:m()`. - Fixed a number of cases where the decompiled code didn't behave like the original, mostly in optimized files: a loop could read a value after it had already changed, an argument could come back as `nil`, functions meant to be made fresh each time came out shared, and some calls ran in a different order. - Fewer functions come out as `error(…)` stubs, especially in optimized files, loops that skip ahead with `continue`, long `and`/`or` conditions and very large modules. - `-g2` keeps more of your names: loop variables, declaration lists, swaps and the names around inlined calls. `for _, b` no longer prints as `for _1, b`. `-g1` keeps each statement on its recorded line, and `-t1` brings back the types of loop variables and upvalues. - `@native` is printed on each function that had it, and a local named `goto` keeps its name. - Fixed hangs: a module that never finished at `-O2`, deeply nested `if` expressions, and a small place file that could use gigabytes of memory. - Large files decompile faster and use less memory. ## 2026.10.16-536f5dc · readability presets and model names - Choose how the output reads: `faithful` (the default, unchanged), `balanced` or `readable`, or turn single switches on and off, in the viewer or through the API. A switch changes only how the code is written, never what it does. See [readability](https://unreadable.gg/docs#readability). - The switches: roll an unrolled loop back into `for i = 1, 3 do ... end`, return early with `if not c then return end` instead of nesting the rest of a function in one `if`, write `n *= 3` for `n = n * 3`, drop unused values and branches, write `16` for `2 * 8` or `math.pi / 2` for `1.5707963267948966`, write a small helper's call as its body, add blank lines between unrelated steps, add the types inference proves, and note on each function's first line which outer variables it captures, like `-- upvalues: speed (copy), pos (ref)`. - Model names: with `balanced`, `readable` or the `modelNames` switch, a naming model names the values your file left unnamed, so `local v3 = arg0.Health` can read `local health = player.Health`. It never picks a name that could change the program, and if the model is busy or down you get the usual names and a note saying so. - Cleaner default output, with the same meaning: methods print as `function Spring:getTarget()` using `self`, functions as `function f()` rather than `f = function()`, services and modules take their names, as in `local Players = game:GetService("Players")`, non-empty tables print as `{ 1, 2 }`, guard clauses get a line each, and `t.a.b -= e` no longer goes through a temporary. - At `-g2`, a function that captures outer variables keeps its own name, where before it printed as `v1`. - Fixed two cases where the output ran differently from the input: a captured local first set by an `if a then b else c` expression, and an unused function inside a `do` block at `-O0`. - The first line now says only how long the decompile took, plus the naming time when names came from the model: `-- unreadable.gg ~ 140ms (+3.2s naming)`. The setting used comes back as `setting` in the result. - Agents can use the decompiler through its [MCP server](https://unreadable.gg/docs#mcp), and send a file straight from disk with `POST /v1/handles` rather than passing it through their context. - The API can stream progress: send `Accept: application/x-ndjson` and get one line per stage (decompiling, renaming, done). - The `unverifiedWarning` option is gone. A file whose opcodes are numbered differently from stock Luau gets an `unverifiable` [note](https://unreadable.gg/docs#notes), never a comment at the top of the source. - Large files decompile faster and use less memory. ## 2026.10.12-3553b00 · 100 free decompiles a day during the beta - Beta only: while unreadable.gg is in beta, you get 100 free decompiles a day instead of 5, on the site and through the API together. The limit will change when the beta ends. - Cleaner `-O2` output: functions the compiler inlined, even deeply nested ones, print as calls like `f(1) + f(3)` again, not as long runs of copied code. - Large files decompile up to 4× faster while using half the memory. - Hand-edited or malformed files: where the output can't match exactly, it now carries a [note](https://unreadable.gg/docs#notes) instead of looking clean. For example, a function that uses more locals than the compiler allows, or that reads `...` without declaring it. ## 2026.10.11-7046806 · the site and the api are live - Decompile a file in your browser: drop it on [unreadable.gg](https://unreadable.gg). The source shows beside your file's bytes, with its verdict, ready to copy or download. - Scripts and agents can send the same file to `POST https://api.unreadable.gg/v1/decompile` and get back the source, a [verdict](https://unreadable.gg/docs#verdicts), [notes](https://unreadable.gg/docs#notes) and the version that wrote it. See the [API docs](https://unreadable.gg/docs). - Every connection gets 5 free decompiles a day, on the site and through the API together, with no key or account. Errors and refused decompiles don't count. - Your files are never stored or logged: see [privacy](https://unreadable.gg/privacy). ## 2026.10.11-4227ea3 · bytecode version 14 - Reads bytecode version 14 and the calls current Luau compilers write by default. Modules from them used to be refused. - Notes and errors carry fixed codes a program can act on, such as `function-refused` and `not-luau-bytecode`: see [notes](https://unreadable.gg/docs#notes) and [errors](https://unreadable.gg/docs#errors). - A function the compiler kept from code it removed, such as the body of `if false then … end`, is printed back inside `if false then … end` instead of being left out. Where it has to guess what the function captured, the guess is marked. - Fixed a case where the first statements of a loop could be left out of the output. - The parameters of a function that also takes `...` keep their names, and so do locals that are copied and read later. - `local a, b = -0.5, #x` prints as one list, as it was written. - Very large modules decompile much faster at `-O0` and `-O1`. - Three more shapes are refused rather than guessed: a branch whose inner arm computes a value, a table constructor whose parts don't agree, and a function that needs too many passes to settle. ## 2026.10.03-4be51f9 · `continue` in nested loops - A `continue` inside nested loops comes back as `continue`. - An assignment to several places at once, such as `a.x, b.y = 1, 2`, comes back as one statement. ## 2026.09.27-1dc49cb · fewer wrong programs - Fixed several cases where the output could have run differently from the input: assignments to undeclared and captured variables, and generic `for` loops left by a `return`. Where one can't be printed faithfully yet, it's refused instead. - A module that had started taking seconds to decompile is back to a few hundred milliseconds. - The decompiler builds for WebAssembly, which is how the site and the API run it. ## 2026.09.20-55cbf5f · conditions as one expression - Conditions with three or more tests print as one `and`/`or` expression. - A variable a loop carries from one pass to the next is no longer declared again on every pass. ## 2026.09.02-39ae4d2 · `and`/`or` as values - `and`/`or` chains used as values fold back into one expression, as in `local x = a and b or c`. - A damaged or crafted file can no longer make the decompiler reserve memory for more than the file holds: every count it reads is checked first. ## 2026.08.30-f0c3823 · table constructors and recorded types - Table constructors come back as `{ … }` instead of a table filled in one assignment at a time. - A type the bytecode records for a local is printed with it, as in `local w: number`. - `if not` keeps its two arms in the order they were written, and a `do … end` block keeps its locals inside it. - A statement that starts with `(` gets a `;` before it, so it isn't read as a call on the line above. - A file from another Lua version is refused by name instead of read as Luau. See [Lua versions](https://unreadable.gg/versions). ## 2026.08.23-38b2adc · first version - Decompiles Luau bytecode back into source, and marks or refuses what it can't reproduce instead of guessing. - Reading a file can't crash the decompiler: a crafted file that would overflow its stack is turned away, and nothing on the way in may abort. --- # unreadable.gg privacy unreadable.gg keeps as little as it can. Your files are never stored. The source that comes back is stored only for an agent that asks for a handle to it, for one hour, locked with a key only that agent holds. Without an account, nothing we keep can be traced back to you after 25 hours. An account's data is kept until it's deleted on the schedule in the [accounts section](https://unreadable.gg/privacy#accounts). Last changed November 1, 2026. ## who unreadable.gg runs this site and the API, and decides what happens to the data on this page. Questions and requests go to [support@unreadable.gg](mailto:support@unreadable.gg). ## your files A file you decompile, on the site, through the API or by an agent, is decompiled and dropped once the answer is sent. It's never stored, never written to a log, and never used for anything else. Unless an agent asks for a handle (below), the same goes for the source that comes back. While the site shows a result, your browser keeps the file in the page's memory so it can be sent again with other settings; closing the result or the page drops it. A place or model file (`.rbxl`, `.rbxm`, or their XML kind) you open on the site never leaves your browser: only its scripts are sent to be decompiled as above, and the new file is built in your browser. If you're signed in, its scripts are decompiled as your account. ## agents When an agent asks for a [handle](https://unreadable.gg/docs/agents) instead of the source, the result is kept for one hour so the agent's later calls can read it. It's locked with a key that exists only in the handle the agent gets back. We never keep the key, so without the handle nobody can read the result, us included. Nothing kept with it says who sent the file. When the hour ends, the handle stops working and the result is deleted. ## accounts An account uses a number as sign-in, no email or password. We store keyed hashes of the account number and each key. For each key we keep its label, creation date, and last used date. We also keep your membership end date, its tier and how it was given, plus your saved site settings, and an account's decompile counts and decompile time for each day for 90 days; we can see those numbers, never what was decompiled. Each site sign-in is kept as a keyed hash of its sign-in token, with when it was made and when it ends. For any account, IP addresses aren't stored. The networks (internet providers) your account is used from in a day are kept only as scrambled names to enforce the network limits, and are deleted when that UTC day ends. You can revoke any key on the [account page](https://unreadable.gg/account). Accounts that never had a membership are deleted 90 days after creation. Those with a membership are deleted a year after the membership ends or a year after a key or a site sign-in is last used, whichever is later. Ask [support@unreadable.gg](mailto:support@unreadable.gg) to delete it sooner. We can also revoke an account's keys or delete the account ourselves, for example when you ask us to. ## your ip address The [free decompiles](https://unreadable.gg/docs/api#free) and the [limits](https://unreadable.gg/docs/api#limits) are counted per connection, so each request's IP address is used to find its count. The address itself is never stored: the count is kept under a scrambled name that can't be turned back into an address, and the count is just a number. The day's counts are deleted an hour after that day ends (UTC). From then on, nothing links a count to an address. Backups are kept for up to 30 days, and they hold only those numbers. ## logs Each request writes one log line: whether it worked, how long it took, a rough size of the file, and whether it was free, a member's or ours. It holds no address and nothing from the file. Logs are kept for 7 days. ## daily totals For each UTC day, the API keeps totals only, split by free, member and owner: how many decompiles it ran and how many it refused, by kind, the decompile time they used, and how many different free connections and member accounts were active. For the day as a whole they also count how many decompiles failed, by kind; how many results came back clean or marked; which way requests came in (the site, the API, the stream or an agent); which preset was asked for; the size of the file, in rough buckets; and when the free budget ran out. The totals hold no address, connection name, key, account number or anything from a file. They're kept for 400 days. ## email If you write to support@unreadable.gg, we get your address and your message, and use them only to answer you. Mail is kept by our email provider. Ask, and we'll delete it. ## providers The site and the API run on a hosting provider that processes data for us under a data processing agreement. To deliver traffic and stop attacks, it sees each request's IP address and headers, and keeps some of them in its own security logs. Its network spans many countries; transfers outside the EU are covered by the EU-U.S. Data Privacy Framework and the EU's standard contractual clauses. Ask, and we'll tell you who our providers are. ## cookies We set one cookie, and only when you sign in on the [account page](https://unreadable.gg/account). It keeps you signed in and holds nothing about you. It lasts 30 days, and signing out deletes it. While you're signed in, the settings you pick are saved on your account, so they're there next time. Signed out, nothing is saved. Nothing else is stored in your browser. There are no analytics, ads or trackers, and nothing is loaded from other sites. When it checks a visitor for bots, our hosting provider may set a short-lived security cookie of its own. ## why We process this data because we have a legitimate interest in running the service and keeping it from being abused (article 6(1)(f) of the GDPR). ## your rights You can ask what we hold about you, have it corrected or deleted, and object to how it's used. Since counts and logs can't be traced back to an address, and a stored result can't be read without its handle, there's usually nothing of yours we could find, except mail you sent us. You can also complain to your data protection authority. If this page changes, the date at the top changes with it. --- # unreadable.gg terms The rules for using unreadable.gg, the site and the API. By using either, you agree to them. Last changed October 29, 2026. ## the service unreadable.gg decompiles Luau bytecode. The free tier and the membership come as they are, with no promise that the service is always up, that it can read every file, or that its output is right. Every result comes with a [verdict](https://unreadable.gg/docs#verdicts) on how far to trust it. ## your files Only decompile files you're allowed to: your own, or ones whose owner lets you. You're responsible for the files you send and for what you do with the result, including respecting other people's copyright and licences. We claim no rights to your files or to the source we give back. What happens to them is on the [privacy](https://unreadable.gg/privacy) page. ## fair use Stay within the [limits](https://unreadable.gg/docs/api#limits). Don't try to get around them, for example by spreading requests over many addresses, and don't attack, probe or overload the service. We may block traffic that does. ## changes We may change the service, its limits or these terms, or stop the service, at any time. If these terms change, the date at the top changes with them. ## liability As far as the law allows, we're not liable for any loss or damage from using the service or its output. Nothing here limits rights the law doesn't let us limit. ## contact [support@unreadable.gg](mailto:support@unreadable.gg)