Skip to main content
Status: proposed Related: ADR-001, ADR-003, ADR-005, ADR-007, ADR-011, ADR-018, ADR-019, ADR-020, ADR-021

The problem, stated plainly

Cordial embeds Deno to run plugins, and the justification on the record is sandboxing: a plugin is untrusted code, so it runs somewhere it cannot reach the filesystem or the network except through a broker. That justification is circular while plugins have nothing to compute. Look at what the one real plugin does. FPS Flex reads a few preference values, maps them to a set of flag names, and writes a JSON document that is consumed at the next launch. Every capability it holds — flags.read, flags.write, settings.read, log — is spent producing a static file. That is a build step. A declarative flag profile with conditionals would do the same job, and the sandbox would be protecting a program that never makes a decision. If that is the ceiling, the honest conclusion is to delete the runtime and ship declarative flag presets. This ADR argues the ceiling is wrong, and says what has to be true instead.

Decision

A plugin is a program that observes the running client, decides something, and acts on it. Cordial provides all three, or it should not provide a runtime at all. Three things are added, and they are one thing in three parts:
  1. An event bus. A plugin subscribes to what the client is doing.
  2. Live effects. Some acts take effect now rather than at the next launch, and the API never lies about which.
  3. Contributed UI. A plugin can draw, in Cordial’s own window, over the engine’s canvas.

The admission rule for any future capability

Could a static configuration file do this? If yes, it is configuration and belongs in the manifest, where ADR-020 already puts preferences and ADR-021 already puts assets and flags. If it requires observing something that is only known while the client runs, deciding from it, and acting before the moment passes, it is a program — and then the sandbox is containing something that genuinely has reach, which is the only condition under which embedding a JavaScript runtime is worth its cost. Applied to what exists today, this rule says FPS Flex should largely be declarative, and that the interesting version of FPS Flex — one that watches frame time and gives quality back when a scene gets cheap — is a program.

1. The event bus

cordial-plugins/src/events.rs has an EventRegistry and essentially nothing flowing through it. The client already knows everything below and tells nobody: Rules, each of which exists because of something already learned here:
  • Delivery is best-effort and never blocks the client. A plugin that is slow, wedged or crashed must cost frames from nobody. The engine’s pump is the one thread this project has repeatedly had to defend; nothing subscribed gets to stall it.
  • Events are facts, not requests. A plugin cannot veto a join or swallow a keystroke. Anything else is an in-process hook wearing a subscription, and ADR-001 rules that out permanently — not disabled, absent.
  • Payloads carry no secrets. No .ROBLOSECURITY, no auth headers, no text the user typed. input.rs already redacts its trace line because it “used to print a password in full on every keystroke”, and an event bus is a much wider pipe than a trace line.
  • Subscription is a capability, per event family, granted like any other under ADR-003’s default deny.

2. Live effects, and honesty about which is which

flags.rs records something measured that inverts the obvious framing, and the API must be built on it rather than around it:
Being re-read is a cost, not a capability. The engine fetches Roblox’s settings document itself about two seconds in and applies it over the top, so a DF* override of a key Roblox also sets is reverted to Roblox’s value while the client is still starting.
So FFlag/FInt/FString are durable and take effect at the next launch. DFFlag/DFInt/DFString are live and revocable, and govern the first couple of seconds unless Roblox never sets the key. Dynamic is the weaker guarantee, not the stronger one. Capability::FlagsWrite and Capability::FlagsWriteDynamic already exist as two capabilities. What has to follow:
  • The prefix is validated and mismatches are errors. Writing FFlagX through the live primitive must fail loudly. The engine will never re-read it, so it would report success and do nothing — the stub-that-lies AGENTS.md exists to forbid.
  • The result says when it took effect, applied_now or applies_next_launch, because that is the only thing a plugin needs in order to tell the user whether to relaunch.
  • Neither is named for speed. A plugin author who reads “dynamic” as “better” picks the one that silently stops working.

3. Contributed UI, over the engine’s canvas

This is the strongest justification of the three, because drawing a panel from declarative config is painful and drawing it from code is easy — and because it is the only one that gives the user something they cannot get any other way: a web view, a toast, or a plugin panel on top of Roblox, in one window. The mechanism is not a hook and not a second toplevel. GTK owns the xdg_toplevel (ADR-011) and the engine’s canvas is a wl_subsurface of it, today placed above the parent, with webview_dialog_opened/webview_dialog_closed lowering it for exactly as long as a dialog is open. That is why a web view covers Roblox entirely instead of sitting over it. The intended arrangement inverts it: the engine’s subsurface sits permanently below the parent, and the parent is transparent and input-transparent over the canvas region. Then every GTK widget composites above the engine for free, and a plugin panel is an ordinary widget rather than a special case. Measured on 2026-08-23, and it works. All three questions were open when this ADR was written; all three now have answers, taken under cage in a nested compositor with grim for the composited screenshots and WAYLAND_DEBUG=1 for the protocol.
  1. Transparency: yes, and it needs less than expected. A window.background { background-color: transparent; } CSS provider on the toplevel is sufficient.
  2. The engine shows through, and GTK’s opaque region was the whole obstacle. With an opaque background GTK sends set_opaque_region covering the entire surface, and a compositor is then entitled to skip painting anything beneath it — which is the real reason behind the module doc’s warning. With the CSS change GTK stops sending set_opaque_region at all, having worked out for itself that the surface is no longer opaque, and no manual override is needed. Three-way control: place_above shows Roblox; place_below without the CSS shows a flat libadwaita background with the engine presenting frames nobody can see; place_below with the CSS shows the Roblox landing page with GTK’s header bar composited over it.
  3. Input falls through, and only because the region is punched. GTK does not shrink its input region when its content becomes transparent — it sends set_input_region once for the whole surface plus the CSD shadow and never revisits it. An explicit set_input_region excluding the canvas rectangle works and is not clobbered. With it, two synthetic clicks inside the canvas produced four nativePassMouseButton calls into the engine; with the punch removed and nothing else changed, the same clicks produced zero.
So the fallback — a dedicated overlay subsurface Cordial paints itself — is not needed, and the WebKit view is reachable after all. Two things remain untested and must be before this becomes the default. Whether the input region survives a window resize or a header-bar re-layout, since GTK owns the surface and recomputes geometry on both; and what happens on a compositor that ignores an empty opaque region. A resize that silently restored the full input region would swallow every click into the canvas with no visible cause, which is a worse failure than the one this replaces. Land it behind a flag, test the resize, then flip the default.

What a plugin declares, and the escape hatch that is not a channel

A plugin never receives a wl_surface, a GL context, or a handle to anything Cordial composites. It declares content and Cordial draws it, which is ADR-007 applied to pixels rather than to sockets. The vocabulary is three things:
  1. A toast — text, a position, a duration. The server-location indicator is this and nothing more.
  2. A panel — the declarative widget set ADR-020 already defines for preferences, rendered somewhere other than the settings window.
  3. An image — the plugin renders offscreen and hands over a buffer.
The third exists because refusing it makes the other two a cage. A plugin wanting a chart, a minimap or a custom readout has no way to express it declaratively, and a design that answers “you cannot” invites somebody to propose a surface capability again in six months. Pixels are content; a surface is a channel, and handing over a finished image keeps every property that matters:
  • it cannot wedge the compositor, because the plugin never commits and never blocks — a slow plugin yields a stale frame, not a hung client;
  • it is droppable, because Cordial owns the frame budget and can skip a plugin’s buffer while the engine is busy, which is impossible if the plugin holds the surface;
  • it is bounded, because a size and a refresh cap are enforceable on a handover and unenforceable on somebody else’s surface.
Two constraints ride with all three, and they are not negotiable. Plugin content is always visually attributed. A plugin’s pixels are visibly a plugin’s — Cordial draws the frame around them, and a plugin cannot suppress it. Plugin content can never cover Cordial’s own chrome and can never go fullscreen. These are the constraints browsers put on extension UI, for the reason that applies here with more force: anything composited over Roblox can be drawn to look like Roblox. A plugin able to paint arbitrary unattributed pixels over the canvas can paint a convincing sign-in prompt, and this project keeps the user’s .ROBLOSECURITY in the desktop secret service. Attribution and the chrome exclusion are what make an overlay a feature rather than a phishing surface, and they must be enforced by the compositing code rather than requested of the plugin. A compositor-level screenshot can now answer all three, which was impossible until a4abe15: --headless runs the client under a wlroots compositor we control, and wlr-screencopy will photograph the composited result. The swapchain screenshot cannot — it shows the engine’s own output and looks identical whether or not GTK is covering it.

What this does not change

No in-process code execution against the Roblox process. No hooking, no memory patching, no injected script environment. An event is something Cordial observed and chose to publish, not a callback the engine calls. A live flag is a value the engine re-reads of its own accord. A contributed panel is a Wayland surface Cordial composites. None of these is a primitive a fork could extract into a hook, which is the property ADR-001 and ADR-003 exist to preserve. Plugins still receive effects, not channels (ADR-007). A plugin subscribing to place.joined does not get a socket; it gets a payload. A plugin drawing a panel does not get a wl_surface; it declares content and Cordial draws it. Default deny still holds (ADR-003). Every event family and every UI surface is a capability, granted per profile, and a plugin that has been granted nothing observes nothing — which is what a freshly installed plugin does today, and what the Plugins page now names the profile for.

Consequences

Cordial gains a plugin API that can express an adaptive quality controller, a presence integration that reflects the actual place, and an overlay — none of which a configuration file can express, and all of which need containing. The sandbox stops being a precaution around a config generator and starts being the thing that makes untrusted programs safe to run, which is the justification the runtime always claimed. The cost is a much larger surface to keep stable, and a real risk that the event bus becomes a way for a plugin to slow the client down. The best-effort, non-blocking rule is the whole defence, and it needs a measurement, not a comment: an event bus with a deliberately wedged subscriber must cost zero frames against a control with none, or this ADR is wrong about its own premise.