Decision
Plugin-contributed FastFlags are exposed through two interfaces, not one:- A launch-time resolver. Plugins declare flags in
~/.local/share/cordial/plugins/<id>/flags.json. Cordial reads every layer before the engine process starts, resolves them, and merges the result into the client-settings document the engine is given. - A runtime API, restricted to the
DFFlag/DFInt/DFStringfamily, which a plugin may call while the client is running.
Why
Because the engine reads the two families at different times, and this is measured rather than assumed.FFlag, FInt and FString are consumed once,
during nativeInitClientSettings, roughly 100 ms into startup. Only the
DFFlag/DFInt/DFString family is re-read while the client runs. That is what
“dynamic” means in Roblox’s own naming, and it is the whole constraint.
A single unified API would be a lie for most flags. The natural design — a
service plugin that manages flags, lazily loaded the first time something imports
it — cannot work for startup flags, because by the time any plugin host is up the
engine has already read them. An API that accepted FFlagSomething from a
lazily-loaded plugin would appear to succeed and do nothing. That is the exact
failure mode this project keeps finding elsewhere: a call that returns
successfully and has no effect, which is far more expensive to debug than a call
that fails.
Splitting them makes the constraint visible at the point of use. A plugin
author writing to flags.json can see it is a file read at launch. A plugin
author calling the runtime API gets an error for a non-DF flag, at the moment
they try, rather than a silent no-op discovered three days later.
Layering already carries provenance, and the same rules apply here. The
resolver is implemented in
crates/cordial-runtime/src/flags.rs:
the user’s file always wins over any plugin, plugin-against-plugin conflicts are
reported rather than quietly resolved, and every effective value records which
layer set it. Extending that to a runtime API means the same guarantees, not new
ones — in particular, a plugin must not be able to override a value the user
set explicitly, whichever surface it uses.
Consequences
Accepted: a plugin that needs a startup flag must be installed, not merely imported. Itsflags.json has to exist before launch. That is a real constraint
on plugin design and it should be documented where plugin authors will hit it.
Accepted: the runtime API needs a way to report “this flag exists but cannot
be changed while running”, distinct from “this flag does not exist”. Collapsing
those two into one error would leave authors unable to tell a typo from a
lifetime mismatch.
Accepted: changes made through the runtime API are not persisted by default.
A flag set at runtime is gone on restart unless the plugin also writes its
flags.json. Making runtime changes implicitly persistent would mean a plugin
could permanently alter a user’s configuration from a call that reads like a
temporary adjustment.
Open: whether the runtime API writes through the engine’s own dynamic-flag
refresh or requires Cordial to re-present the settings document. That is an
implementation question and it has not been established by experiment yet.
Whoever builds it should determine it by running something, not by reading the
binary — see docs/NEXT.md for why that rule exists here.
What would change this
If a future Roblox build re-read the static families at runtime, the split would become unnecessary and the two surfaces should collapse into one. That is checkable: set anFFlag with an observable effect while the client is running
and see whether behaviour changes. It does not today.
Notes moved from docs/fastflags.md (2026-10-02)
A documented example is the first thing anybody copies, so check it. The page’s example once carried"FStringDebugGraphicsPreferredBackend": "Vulkan", which
reads perfectly and is not a Roblox flag: DebugGraphicsPreferredBackend appears
zero times in libroblox.so, and nothing resembling it does either. The real names
in that family are DebugGraphicsDisableVulkan, DebugGraphicsDisableOpenGL,
DebugGraphicsDisableVulkan11 and so on. A user reported it and it was checked
against the binary. An unknown name goes into the settings document like any other
key and nothing rejects it, so an invented flag looks exactly like a working one;
the engine’s own table stores the name without the FFlag/FInt/FString prefix,
which is why the check is strings libroblox.so | grep -x <name without prefix>.
The backend is a Settings choice because Cordial decides it before the engine starts.
The Android client has no frame-rate row in its menu, and nothing here can add
a row the client does not draw, so raising the frame rate takes two separate levers:
Frame pacing (VkSwapchainCreateInfoKHR::presentMode, whether a finished frame
waits for the next refresh) and Frame rate limit (what the engine’s own scheduler
aims at). Leaving Frame pacing on FIFO caps the rate at the panel’s whatever the
limit says. It was first reported as a missing feature, which is a fair reading of
an interface that has no such control. Measurements behind the caps (nothing above
240, a cap above 60 measuring worse on a 60 Hz headless output) are in
ADR-051.
Roblox’s graphics-quality FastFlags (DebugFRMQualityLevelOverride and the MSAA
overrides) were tested and change nothing here, because they govern 3D scene
rendering and the signed-out landing page is a 2D interface. Render resolution and
density (CORDIAL_RESOLUTION, CORDIAL_DPI_SCALE) are the levers that apply to it.
CordialDeviceProfile in flags.json is INFERRED to have no effect.
flags::device_profile() reads the environment first, then the flag layers, but
nothing outside flags.rs calls it; native/init_params.cpp reads
CORDIAL_DEVICE_PROFILE directly, which the shell sets from the Graphics
optimisation row. Established by reading callers, not by running a client.
Flag-family timing. FFlag/FInt/FString are read once at startup; only the
DF family is re-read while the client runs. That matters to anything that changes
flags dynamically: a plugin loaded part-way through a session cannot change a
startup flag, whatever it writes. Overrides are merged into the settings document
the engine is given at startup, and the launch log reports how many were applied.