How Pandora works
Pandora writes FastFlags into a Roblox client that is already running. It reads the value before every write and again after it. Below: what it checks, what it refuses, how fast it is.
Numbers on this page come from one PC. Yours will differ. Paper (PDF, in Korean)
Six layers, one way to write
Pandora is a stack. Each layer talks only to the one below it, and only the bottom layer reads or writes the client's memory.
So everything above the bottom can run with no Roblox open: swap L0 for a fake and nothing else changes. Attaching, building the index, the re-apply loop and the diagnostics sit on top of the stack.
-
Every refusal is recorded with the stage it stopped at: Discovery, Resolve, Contract or Transaction. The records sit in a ring buffer. Reasons are off normally;
--fflag-whyturns them on for one run. -
The only function that writes. It runs the gates in the next section, writes only when the value differs, and reads the value back.
-
One contract per type, checked on the slot: the memory where a flag's value lives. A bool byte is 0 or 1, a float is a finite f32, an int slot is not half of a pointer, a string is a valid
std::string. A slot that breaks its contract is refused (Refuted) before anything is written. -
Collects evidence about a block of memory. The verdict is Valid only with at least two pieces of evidence and at least one of them about content. You can try it below.
-
Two small enums. Knowledge says what a piece of memory is: Valid, Invalid or Unknown (default Unknown). Operational says what may be done with it: ReadOnly, WriteCandidate or WriteBlocked (default WriteBlocked). WriteCandidate means writable once it passes the contract checks.
-
Four calls: read, write, is this address writable data, and the module's address range. Layers 1 to 5 reach memory only through these four, so a test can hand them a few bytes instead of a process.
How does it know a block of memory is the flag registry? It collects evidence. Structure alone says little: any object that holds a pointer and a small number looks the same. Content is what settles it.
Structure (any object can have it)
Content (only the registry has it)
Verdict Unknown
What happens on one write
Every write goes through one function, apply_typed. It runs the gates below in order and stops at the first one that says no. A slot is the memory where a flag's value lives.
- 0Never-write listNames that must never be written. Empty today.
- 1Read the slotIt must look like its type.
- 2PolicyA type can be marked read-only. None is.
- 3Changed?The same value is already there: stop.
- 4EncodeInts clamp to 32 bits. Strings must fit.
- 5Aligned?1 byte always. 2, 4 and 8 bytes need an address divisible by the size.
- 6Writable page?Committed, non-executable data.
- 7WriteStrings also update the size, never the capacity.
- 8Read it backEqual: done. Different: try again next cycle.
What it refuses to write
Three crashes came from writing without looking at the target first. In all three, one read right before the write would have blocked it. That is the premise of the design.
A stale address
The offset table was out of date. A 4-byte write at base + offset landed on a live pointer.
Raw bytes over a string
A value written as plain bytes over a std::string. The next access raised an exception.
An int in a bool
A 4-byte int written into a 1-byte bool slot. Three neighbouring values were overwritten.
What the read checks, by type:
- bool
- One byte, 0 or 1. Anything else, like 254, means the slot is not a bool.
- int
- Four bytes. The slot must not be half of a pointer (checked below). Values clamp to a 32-bit signed int:
9999999999999999used to wrap to 1,874,919,423 and now stays at 2,147,483,647. - float
- Four bytes, a finite f32. NaN and infinity are refused.
- string
- A
std::stringas MSVC lays it out: a 16-byte union of text or pointer, the size at +0x10, the capacity at +0x18. Valid when the capacity is 15 to 1,048,576 and the size fits in it. Empty is valid.
The int contract needs more than a look at the bytes. An int slot sits 4 bytes from the next one, so an 8-byte read returns our value and the neighbour's. Those 8 bytes could also be one pointer. Three checks tell them apart.
- Shaped like a pointerbetween 0x10000 and 0x7FFFFFFFFFFF, upper half not 0, divisible by 8
- Our half is a big numberthe lower half, read as a signed int, is beyond ±268,435,456 (0x10000000)
- The address can be readreading 8 bytes there works
Changing servers
Moving to another server rebuilds the DataModel. Addresses Pandora held now belong to other objects. Four crash logs all ended in HardAssert (Could not find class descriptor) right after submitStartGameTask.
Guessing a hop from a memory sample only works after a cycle has already written, maybe into freed memory. So Pandora reads Roblox's own log instead, only the part added since the last look. On submitStartGameTask it skips that cycle's write, drops its caches and rebuilds the index on the spot. When the log can't be read, it falls back to the memory guess.
Tested with a synthetic log: one 2 s cycle skipped, then the earlier numbers came back. The index rebuild took 359 ms. A real hop can't be triggered from the command line, so this has not been reproduced in a live game.
From launch to first flag: 8 s to 1 s
Time from launching Pandora to the first flag written. The old build waited 2 s before its first look, then took 3 s to build the index and 3 s more to apply. The new one doesn't wait, and the other two steps got shorter.
| Old | New | |
|---|---|---|
| Wait before the first look | ||
| Build the index | ||
| First apply |
What got cheaper
Each row is the same work, before and after. The faint bar is before, the strong bar is after. Each row has its own scale.
- Failed reads Dead pages were asked about again and again: 30% of all calls. Failures are now remembered.
- Reads per 64 KB swept The sweep now reads 64 KB blocks, not 4 KB pages.
- Candidates Filtered inside the scan buffer, with no extra reads.
- Confirming candidates Fewer candidates reach the confirm step.
- Choosing among candidates A name with one candidate already has its answer, so its slot is not read. That is 36,821 of 41,039 names.
- Missing-name lookups 227 names. A “not found” is remembered instead of looked up again.
The page cache used to copy a whole 4 KB page on every hit. Building the index copied 4.26 GB that way. It now copies only the bytes it was asked for.
The missing-flag report was 80% of one apply. It made 11.5 million comparisons on a list that had not changed: 195 to 299 ms, next to 25 to 45 ms for the writes. The names are now sorted and found by binary search, and nothing is computed while the list stays the same.
Every missing flag used to start an index rebuild in the middle of the apply. One took 1,390 ms and found 3 names. The index is now built in the background and swapped in whole when it is finished. At first it was only moved to a background thread, and the apply time stayed the same: the rebuild emptied the live index before filling it, so every lookup in the meantime missed. Building and installing had to be separate steps.
A “not found” is remembered, but not for long. The memory is dropped whenever the index changes, and every 5 minutes anyway. One read that missed in a busy game must not keep a flag out for good.
Nothing is written to disk when the content hash is the same, and flags.json is not parsed again when its modified time and size are the same. At a 2 s cycle that is about 40,000 calls a day that now do nothing.
Threads
Building the index means sweeping the game's heap. The sweep is cut into 1 MB chunks, 128 to a batch (32, 64 and 128 all took about 420 ms, 256 took 585), and spread over threads. The results are merged in chunk order, so the candidates come out in the same order as with one thread.
- Pandora's setting
Skipping file-backed memory
File-backed memory (MEM_MAPPED) is 25% of the bytes, 503 MB, but only 1.4% of the candidates, and none of those became a list. The fast sweep skips it. That is something seen on this client, not a rule, so it is checked: when a fast run finds more than 0.5% fewer names than the run before, everything is read once more. --fflag-scan-diff compares the two sweeps name by name.
The suggestion model
FlagML is a small neural network that ranks flag names by how much they look like the ones you apply. It only orders names. Nothing in the write path reads its output.
A name becomes 256 numbers
The network takes 256 numbers, so a name is counted first. Lowercase it. Slide a window over its bytes: 2 wide, then 3, then 4. Hash each window with 64-bit FNV-1a, take the result mod 248 and add 1 to that cell. Cells 248 to 255 are for the prefix. At the end every cell is divided by the sum of all the cells, so a long name does not outweigh a short one.
- Windows
- Cells lit
- Cells shared by different windows
- Prefix cell
- Sum
Two different windows can land in the same cell. The 72 built-in presets hold 3,186 different names. The median name has 93 windows, and 13 of its cells are shared this way. The network only sees the count, never which window it was.
Cells 248 to 255 stand for eight prefixes: fflag, dflag, sflag, ffint, dfint, fint, flog, dflog. A name lights the one it starts with. A DFFlag or FString name matches none of them, so no prefix cell lights.
Scoring and learning
The network is 256 numbers in, 64 hidden units with ReLU, 1 number out through a sigmoid. It starts from fixed weights (seed 42). Each name you apply is one gradient step toward 1, at a rate of 0.02.
It only ever sees names that were applied, never one that was left out. A score therefore says how much a name looks like what you apply. It does not say the flag helps.
After an apply that wrote at least one flag, a background thread scores every name in the index that you are not already applying. The score is half the network's output plus half the average overlap with the names you applied, the dot product of the two encodings. The list is sorted and the top 5 are printed to stderr. The window does not show them.
The weights and the last 5,000 training records are saved every 100 records, after each scoring pass, and when Pandora closes.
A crash log once ended inside FlagML, on an index out of bounds. Training had gone to NaN, the saver wrote that as JSON null, and the loader dropped the nulls, so a row of 64 weights came back with 62. Now a weight that is not finite becomes 0 on every training step, every save and every load. Training and scoring check the shape first: a wrong shape scores 0.5 and trains nothing. A file with any row or vector of the wrong length is thrown away whole, and the network starts again from the seed.
Putting the old values back
Undo writes back what was in the slot before Pandora's first write. It never writes a default: Pandora does not know the defaults of a build, and the value that was there may have come from the server.
The ledger
Every write Pandora confirms adds one entry to a ledger in memory: the flag name, the address of its value, the type, the value Pandora found there and the value it wrote. Before the ledger is saved, and again before Undo, each entry also gets the flag's name tag: 16 bytes read from its descriptor, a pointer to the name and the name's length. A second write to the same slot adds nothing, because the first value is the original. The number on the Undo flags button is the number of entries.
The ledger is saved to fflags\original.json after each apply that adds an entry, with a header: the Roblox process ID, its start time, the module base (where Roblox's code is loaded) and the build. When Pandora attaches to Roblox it loads the file only if all four match. A process ID alone proves nothing, because Windows reuses them. The build is compared only when both sides have one. On a mismatch the file is deleted and Pandora says the saved ledger belongs to a different Roblox process. A file that does not parse is deleted too. Restart Pandora alone and the ledger survives. Restart Roblox and it does not.
| flag | was | wrote | now | checks | result |
|---|---|---|---|---|---|
| DFIntTaskSchedulerTargetFps | was60 | wrote9999 | now9999 | checks | result |
| DFIntTextureQualityOverride | was0 | wrote3 | now1 | checks | result |
| FFlagDisablePostFx | wasfalse | wrotetrue | nowtrue | checks | result |
| FIntDebugForceMSAASamples | was4 | wrote1 | now– | checks | result |
Three checks per slot
Undo looks at each slot three times, in this order. The first check that fails ends it for that slot.
- 1Still that flag?
- The name tag read from the descriptor must equal the one in the entry. Since the write the descriptor may have moved or gone, and its address may belong to something else now. If the tags differ, the slot counts as moved and nothing is written. An entry with no name tag is held to less: its descriptor only has to still look like one.
- 2Still our value?
- The slot is read and compared with the value Pandora wrote, by the same rule the write used, so a clamped int is not mistaken for someone else's change. A slot that cannot be read counts as moved. A value that is not ours counts as changed by Roblox, and Pandora leaves the slot alone: the old value may no longer be the right one. One way to get here: Roblox fetches dynamic (
DF…) flags from the server again about every two minutes and writes over them. - 3Can it be written?
- The old value goes in through the same gates as any other write. If they refuse, the slot counts as failed. It stays in the ledger, and pressing Undo again tries it again. If the slot already holds the old value, nothing is written and it counts as already original.
What else Undo does
Restored, already original, changed by Roblox and moved slots all leave the ledger. Only a failed slot stays. The message counts what happened and leaves out anything that is zero.
Before it touches the first slot, Undo switches off Auto apply (it applies your list every 5 seconds) and Re-apply guard (it puts your values back every 2 seconds). Left on, they would write the list again a moment later: the window would say undone and the game would still have the values.
Undo does not touch flags.json. That file is your list, not the game's state, and you may want to apply it again. If a startup file was deployed with --fflag-file-deploy, Undo puts that back too: the backup returns, or the file is deleted if there was none. A file that changed after Pandora wrote it is left alone.
A flag Roblox reads once, when it builds something, keeps its effect until Roblox builds that thing again. Undo puts the slot back. It cannot take back what the game already did with the value. A flag applied before Pandora started has no entry and nobody knows what it held, so Pandora does not guess. It says nothing is recorded, and only a Roblox restart clears such a flag.
Names it never offers
Some flags have nothing to do with frames and break something real when they are wrong: a login rollout, a token, a payment switch, a server address. Community lists mix them in, and some overwrite a telemetry address with "null". The list Pandora builds for your PC does not.
The check runs first, over every name Pandora knows: the flag index, Roblox's settings table and the presets. It cuts the name into words and refuses it if any word is on a list of 61. It also refuses a name that contains _place or ends in placeid: those flags carry a list of places, and the server gives each place its own value. On the flags I measured, 2,296 names are refused.
This is part of building the list. It is not a gate on writes: a name you put in your own flags.json is not checked, and FlagML's scan does not use it either.
Words, not letters
The first version looked for the listed words as letters inside the name. age is inside Percentage, Storage, Manager and Package. ban is inside Bandwidth. On the flags I measured, that refused 6,387 names. Cutting the name into words at its capital letters and refusing only a whole word refused 1,711 at the same point. That was one snapshot; the 2,296 above is the current count.
Commands
Every command here runs without the window: it prints to the console and exits. Run them from a terminal in the folder Pandora.exe is in. Click a line to copy it. The ones that read Roblox's memory stop with a message when Roblox is not running.
What scan-diff prints
A faster sweep can drop one name and gain another and still report the same count, so counts prove nothing. scan-diff compares the two lists name by name.
Where things live
Pandora keeps its own files in one folder. The startup file from --fflag-file-deploy is the exception: it goes into Roblox's own folder.
- %APPDATA%\PandoraSettings\
- settings.jsonwindow settings
- clientsettings.jsonRoblox's settings table, cached
- pandora_lang.txtchosen language
- fflags\
- flags.jsonyour list
- original.jsonthe undo ledger
- deployed.jsonwhat the startup file last wrote
- canon.jsonnames without a prefix, resolved
- fflag_favorites\*.jsonfavorites
- BuiltedFlag\<category>\the 72 built-in presets
- offset\fflagoffset\offset table, version stamp, learned cache
- fflagWeight\
- model_weights.jsonFlagML weights
- training_log.jsonthe last 5,000 training records
- history\<ms>.jsonone file per revision of the list, 120 kept
Network
- Settings table
- The table of every setting Roblox ships comes from
clientsettingscdn.roblox.com, withclientsettings.roblox.comas the fallback. Pandora downloads it whenclientsettings.jsonis missing, empty or does not parse, and when you refresh it in the app. Otherwise it reads the cached file. - Music
- The music player downloads yt-dlp from its GitHub releases, then asks youtube.com for the audio.
- Wallpaper
- The wallpaper picker queries the wallhaven.cc API.