How Pandora writes a flag
Everything on this page is measured on the developer’s machine, or read straight out of the source. Where a number was never measured, it says so instead of being estimated.
Architecture
Pandora is one binary with no plugin surface. The FastFlag modules were split out of an older, larger program; login, key authentication and the sealed-binary machinery were deliberately left behind. What remains depends on four things internally: memory access, the scorer, the engine, and the translation macros.
| Module | Responsibility |
|---|---|
fflag_engine | The adaptive engine. Read-before-write, type contracts, transactional apply, audit trail. Unit-testable without Roblox running. |
fflag_injector | The live path: registry discovery, offset refresh, auto-apply and re-apply loops, 20 diagnostics. |
flag_ml | Relevance scorer over flag names. Pure Rust, no external crates. |
advisor/* | Bottleneck scoring, evidence corpus, lexicon, combos, and the A/B measurement harness. |
flagdb, flaglist | The flag database (three sources merged) and the 72 built-in presets. |
history | Full-text snapshots of the table, one file per revision. |
ui, pages, app | Design system, screens, and the window shell that ties the editor to the injector. |
Injection path
The engine is layered so that no code path can reach a write without passing the read that justifies it. Each layer only knows about the one below it.
| Layer | What it settles | |
|---|---|---|
| L0 | MemorySource | Process memory as a trait — read, write, is-writable-data, module range. Replaced by a mock in tests. |
| L1 | Knowledge / Operational | A three-state model. Knowing a thing and being allowed to write it are separate questions. |
| L2 | judge_registry | Identifies the registry layout by accumulating independent evidence rather than matching one signature. |
| L3 | TypeContract | Per-type memory contracts for bool, int, float and string, including what a slot must not contain. |
| L4 | apply_typed | Transactional apply: write on change only, then read back to confirm. |
| L5 | AuditTrail | Why a write was refused, kept for later. --fflag-audit separates what was written from what applied. |
The handle
The process is opened with mask 0x0438 — VM_OPERATION, VM_READ, VM_WRITE,
QUERY_INFORMATION. No thread creation, no allocation, no page-protection change: the write is four
to eight bytes over a value that already exists. Older code that asked for PROCESS_ALL_ACCESS and
flipped pages to PAGE_EXECUTE_READWRITE was deleted — nothing called it, and it was
the first shape a scanner looks for.
When offsets go stale
A Roblox update moves things. The status bar says so and the app re-derives offsets on a backoff of 30 s → 1 min → 2 min → 5 min. An apply that lands zero flags is itself the clearest evidence that offsets are stale, so it schedules a refresh and retries once it finishes.
What it refuses to do
Every crash ever observed in this project came from writing while not knowing. All three would have been stopped by a single read before the write, which is why that read is not optional.
- Trusting a stale RVA table and stamping four bytes at
mbase+off, over a live pointer. - Pouring raw bytes over a
std::stringobject — death on the next access. - Writing a 4-byte int into a 1-byte bool slot, corrupting three neighbouring values.
The type contract does not only describe what a slot should hold; it describes what it cannot hold.
A value whose top 32 bits are set and which is 8-byte aligned looks like a pointer, and no
FInt is ever going to be one.
Speed
“It takes forever to inject” is one lump on screen and four stages underneath. Same machine, same Roblox process (2.0 GB), timed from launch to the first flag landing:
| Stage | Before | After |
|---|---|---|
| Waiting for the daemon’s first patrol | 2,000 ms | 0 ms |
| Building the index | 3,013 ms | 880 ms |
| First apply | 2,981 ms | 130 ms |
| Total | ~8 s | ~1.0 s |
The result never changed across the rewrite: 435 of 715 flags applied at every step, and the index
name set stayed identical. --fflag-time and --fflag-ready are the rulers built for it.
Where the time went
- The page cache cloned 4 KB for every 8-byte read. One index build was 4.26 GB of memcpy. The hottest read now allocates nothing.
- Dead pages were re-asked of the kernel. 30,970 failed reads, 30% of all calls; a failed round trip costs what a successful one costs. Caching failures with the same TTL: 1,597.
- The heap sweep was single-threaded. Split into 1 MB chunks: 1,379 ms → 895 → 551 → 448 at six threads. It stops at six because past that you gain almost nothing and take it from the game.
- The missing-flag report was 80% of an apply. 11.5 million comparisons for a list nobody had changed. Sorted names make it a binary search; an unchanged list is not recomputed.
- 36,821 of 41,039 names had exactly one candidate. With one candidate the answer is that candidate, read or no read: 168 ms → 31 ms.
Proving nothing was lost
Counting cannot prove a faster sweep lost nothing — drop one name, gain another, and the count is identical. So the name sets were compared whole:
full sweep 41042 names fast sweep 41042 names only in full 0 ← nothing lost
The older, looser candidate filter was compared the same way — the sorted name lists did not differ by a line, and the missing-flag report matched the previous build byte for byte.
Advisor
A community preset hands everyone the same 300 flags. But an integrated-graphics laptop stalls on the GPU, a four-core desktop on the CPU, an 8 GB machine on memory. The advisor scores those three, weights the flags that relieve the tight one, and prints the reasoning on screen — hide it and the user cannot decide whether to accept the result.
The universe, and what may enter it
Three sources are merged: 35,079 names actually present in this build’s memory, 22,758 from the public server settings table with official types and current defaults, and 3,194 values from 72 community presets. All 36,219 are walked; only what can be justified becomes a candidate.
| Aggressiveness | Evidence admitted | Row cap | Rows here |
|---|---|---|---|
| Conservative | Verified only — 53 written by hand, each with an explainable mechanism | 200 | 60 |
| Normal | + proven by name — 499 whose direction follows mechanically | 600 | 423 |
| Aggressive | + community consensus — 90 touched by 6+ presets with 3/4 agreement | 1,600 | 563 |
| Maximum | + weak consensus and unreadable names — behaviour unknown | ∞ | 733 |
Rows measured on the developer’s machine at 60% quality cost. Default is Normal.
Quality cost is a maximum, not a sum
How much visual quality to give up is not a technical question, so it is a slider from 0 to 100%. Cost is counted as the maximum per aspect: lowering shadow resolution after shadows are already off costs no further quality, and summing would price that pairing as double — which keeps the free finishing touches out. The same reasoning caps each aspect’s benefit; no matter how much telemetry you switch off, its ceiling is 1.2.
Measured, not asserted
--advise-measure alternates default and recommended values on a running client, reading CPU
time from GetProcessTimes and GPU from the same counter Task Manager uses.
round 1 A CPU 2657 ms/s GPU 32.9% → B CPU 2441 ms/s GPU 31.9%
round 2 A CPU 2730 ms/s GPU 32.9% → B CPU 2301 ms/s GPU 31.6%
round 3 A CPU 2837 ms/s GPU 32.6% → B CPU 2375 ms/s GPU 32.1%
CPU −13.4% (±4.6%p) · GPU −2.9%
- Alternate, repeatedly. Measuring all of A then all of B bakes the scene’s own drift into the result.
- Pick the process that is drawing. Four Roblox instances on one machine is normal; the first handle found was once idle at CPU 41 ms/s, GPU 0%.
- Inject through the real path. A direct name lookup landed 0 of 240 flags, because the registry stores names without their prefix.
- Pin the frame cap on both sides. Otherwise A and B run at different frame rates and the delta stops meaning anything.
At the same 60% quality cost: conservative, 60 rows, CPU 1651.7 → 1103.8 ms/s (−33.2%, spread ±1.5%p). Maximum, 736 rows, 1576.1 → 1211.8 (−23.1%, ±7.5%p). Twelve times the rows performed worse, because hundreds of telemetry flags do not make frames.
One run read A 55 → B 138, A 48 → B 39, A 47 → B 52, spread ±91.5%p. The game window said
FPS 149.8 · Render 0.7 msec — an empty scene. Where there is nothing to reduce, nothing
you inject changes anything. That is “cannot measure”, not “no effect”, and the screen says which.
FlagML
A small neural network scores how relevant a flag name is, and it is written by hand in Rust with no external crates — the whole model is a JSON file in your profile. It learns online: applying a flag is the training signal.
| Input | 256-dim sparse vector — character n-gram hashing, plus 8 one-hot slots for the prefix (FFlag, DFInt, FLog…) |
| Hidden | 64 units, ReLU |
| Output | 1 unit, sigmoid |
| Training | Online SGD, learning rate 0.02, log capped at 5,000 entries |
| Storage | %APPDATA%\PandoraSettings\fflagWeight\ |
Nothing leaves the machine. There is no server to talk to, and the scorer is a suggestion engine — it never decides on its own that a flag is safe to write.
Safety filter
Names touching addresses, tokens, login, payment, moderation, security or age are excluded from every
recommendation — 2,296 of them. Community lists commonly overwrite a telemetry URL with
"null"; this one does not. That is not a trade worth a few frames.
The filter first matched substrings. age ate Percentage, Storage and Manager;
ban ate Bandwidth. A quarter of the universe — 6,387 names — disappeared silently.
Matching whole words brought it to 1,711.
The advisor also refuses to recommend a name it cannot find in the live index, even when 48 presets contain it. A flag that no longer exists in this build is reported as unverified rather than injected quietly.
Interface runtime
“The window feels laggy” measured out to something other than drawing. The code picked the
first top-level window the process owns, and winit makes several — eight top-level, four
without an owner, two of those invisible helpers. When an invisible one was picked,
IsWindowVisible returned 0, the app decided it was hidden, and pinned repaint at 400 ms.
Three frames a second with the window in front of you. It now picks the largest visible window.
| Where | What it did every frame | Before | After |
|---|---|---|---|
| Presets | Walked the folder and re-read every preset JSON to count rows | 7.80 ms | 0.36 ms |
| Database | Searched 36,000 names and cloned 300 results | 0.31 ms | 0.17 ms |
| Sidebar | Serialised the whole table to JSON for one asterisk on the save button | 0.12 ms | 0.06 ms |
| Anywhere | A process snapshot every 2 s, a 10–13 ms stall | spike | none |
The repaint timer does not arrive when asked
request_repaint_after lands on winit’s 15.6 ms wait granularity: ask for 16 ms and
you wake at 31 (32 fps); ask for 33 and you wake at 46 (21 fps, visibly steppy). Neither
timeBeginPeriod(1) nor leaving the power-saving class moved it. So the app asks for
16 ms. Drawing at vsync costs 2.3% CPU for a slightly smoother gradient — not worth it; scrolling
feels smooth because egui redraws on input, not because of this interval.
| State | CPU |
|---|---|
| Idle, window in front | 0.7 – 1.9% |
| Pointer moving over it | 2.2% |
| Covered by another window | 0.26% |
| Minimised, hidden, or cloaked by DWM | ~0 (2.5 fps) |
Losing focus is not a reason to slow down: Pandora is meant to sit beside Roblox, and a frozen status bar looks like a dead program.
Diagnostics
Every one of these runs without the window, so an experiment repeats quickly and leaves numbers behind rather than impressions.
--fflag-diag attach, score the layout, parse flags.json (no write) --fflag-apply the above, then actually inject --fflag-audit read-only audit: what was written vs what applied --fflag-verify 20 re-check after 20 s that the values held --fflag-repair diagnose entries that did not take --fflag-time [n] break one injection into attach / index / apply --fflag-ready launch to first-writable, measured --fflag-regions region kinds, sizes, candidate counts --fflag-scan-diff fast sweep vs full sweep, by name set --advise 30 1 quality cost 30%, aggressiveness 1 (normal) --advise-sweep 2 walk 0-100% quality cost at aggressiveness 2 --advise-measure 60 12 3 0 A/B: 60% cost, 12 s, 3 rounds, conservative --music-diag <term> search → url → stream → decode → device, timed
Files and network
Everything Pandora keeps lives under one folder. Deleting it resets the program.
settings.json window settings clientsettings.json server settings table, cached pandora_lang.txt chosen language fflags\flags.json the table itself fflag_favorites\*.json favorites BuiltedFlag\<category>\ the 72 built-in presets offset\fflagoffset\ offset table, version stamp, learned cache fflagWeight\ FlagML weights and training log history\<ms>.json one file per revision, 120 kept wallpaper\ · music\ background images, recent track titles only
Network
- Roblox’s public settings table. Fetched only when you press refresh in the Database tab; otherwise the cache is used and nothing is requested.
- Wallpaper search. wallhaven.cc, chosen because it is the only one that needs no API key — a key would have to be baked into the binary. Safety is enforced in the URL (SFW only, no people), so the server filters rather than the client.
- yt-dlp, downloaded once from GitHub into
tools\for the music player. Audio is streamed into memory and never written to disk; only the title is remembered.
There is no analytics endpoint, no update ping, and no account of any kind.