Skip to main content
Draft v0.3, accepted as design in ADR-052 and reshaped by ADR-055. The message types and conformance cases are in the cordial-protocol crate. The built-in runtime, cordial-run, serves this protocol and the launcher speaks it, as the only channel between them (section 8 says what each side does and does not do yet). No other runtime exists, cordial has no code to load one from a manifest, and no --runtime-check command exists; the conformance harness runs against a listening runtime through the crate’s check_runtime example. Treat the sections that no running code exercises as proposals. Sections marked draft are the least settled.
This page is for someone building a runtime. Cordial lists only its own built-in runtime for now (ADR-052), so a runtime written to this spec will not appear in Cordial until the maintainer vets it. One that injects code into the Roblox client will not be listed at all. There are two programs. The launcher (cordial-shell) owns profiles, settings, the FastFlag layers, plugins and their grants, presence, the secret store, doctor, the report screen and the launcher’s own window. A runtime (cordial-run today) is whatever turns Play into a running Roblox client: it loads the engine, owns the game window, reports what happened, and accepts a small closed set of requests. The protocol carries events and effects. It never carries channels or code. A port forks the runtime and nothing else. Why the line is here: ADR-055. The reference implementation of the wire is a crate. cordial-protocol holds the framing, version negotiation, typed messages, the manifest type and a conformance harness. It is MIT OR Apache-2.0, unlike the rest of Cordial, so a launcher or a runtime can depend on it; it needs only serde and serde_json. A runtime in any language can implement this page and the line vectors in the crate’s vectors/ directory without it. Where this page was ambiguous, section 9 says which reading the crate takes.

1. Manifest

runtime.json, searched in $XDG_DATA_HOME/cordial/runtimes/<id>/, then each $XDG_DATA_DIRS/cordial/runtimes/<id>/. Inside the Flatpak the launcher also searches /app/runtimes/<name>/, where a runtime packaged as a Flatpak extension is mounted.
  • spec is the protocol major, written once. The minor lives only in the handshake, so the two cannot disagree. A manifest naming a major Cordial does not know is shown as “needs a newer Cordial”, not hidden.
  • exec is relative to the manifest’s directory. arch is the host architectures the runtime runs on; Cordial hides it on any other and offers no translation (ADR-043).
  • builds says who manages Roblox builds. "cordial" means the launcher’s store (ADR-054) picks one and {build_dir} is that entry. "runtime" means the runtime fetches and keeps its own, and the launcher shows no Version row for it.
  • capabilities has the same shape as the handshake: name to integer version.
  • The manifest is advertisement only, and the handshake is the truth. The launcher’s keyring entries and its report use the manifest’s id, never the one the runtime claims while running; a handshake whose runtime.id differs from the manifest is refused.
  • Placeholders in args: {socket}, {session_dir}, {profile_dir}, {build_dir}, {join_url}. Each is substituted as a single argument, never through a shell. {join_url} is a roblox-player: link the launcher has already validated and stripped of its launch ticket (section 6).
The built-in runtime is the exception in version 1. It keeps the argv and about fifteen environment variables the launcher passes today, and the profile lock descriptor is inherited. The one thing it is told that is a path the launcher chose is the session directory, in CORDIAL_SESSION_DIR, which is what {session_dir} is for a manifest-launched runtime. A third-party runtime gets only the placeholders above; its settings arrive over the socket.

2. Transport

JSON lines, UTF-8, \n-terminated, at most 64 KiB a line. A longer one is a protocol error. The launcher creates <profile>/runtime/<session>/ with mode 0700 and passes it as {session_dir}. The runtime listens on {socket} inside it and the launcher connects, retrying for up to 30 seconds while the runtime loads (the delivery rule ADR-044 already uses). The runtime listens, rather than the launcher, because the launcher can be restarted mid-game and has to find the same runtime again. Plugins never see this directory.
  • One controller at a time. A new connection that completes a handshake replaces the old one, which is sent bye {reason:"superseded"} (either side may send bye before closing).
  • Socket paths are short. sun_path holds 108 bytes and a profile directory under a Flatpak is already long. The launcher keeps <session> to eight characters, and a runtime that finds {socket} too long binds through /proc/self/fd/<dirfd>/ctl.sock instead.
  • Closing the socket does not stop the runtime. Closing the launcher’s window while a game runs is the ordinary case (ADR-012, ADR-031). On EOF the runtime keeps the game running, keeps listening, and expects a controller to reattach. It must also survive a closed stdout and stderr, which the launcher pipes for its crash page. The built-in runtime survives the socket closing and does not yet survive its stdout closing: a launcher that crashes still takes the client with it through that pipe, and only a launcher that is restarted while the first one’s pipe is open, or a client started by hand with its output elsewhere, can be reattached to.
  • A line that does not parse closes the connection, never the runtime.
  • Every request gets exactly one reply. A request with none within two seconds is treated by the launcher as failed, never as success.
  • Replies may arrive in any order and are matched on id. Ids are per direction. Version 1 defines no runtime-to-launcher requests; the shape is reserved.
  • Events carry a counter n. A runtime that produces events on an engine thread hands them to a bounded queue of 256 that drops the newest and counts, and reports the count as events.dropped {count}. A launcher that stops reading must never stall the engine.
  • Everything a runtime sends is bounded and validated by the launcher: strings at most 512 bytes unless a capability says otherwise, ids and counters as integers, enumerations checked against the closed set. Unknown fields are ignored. An out-of-range one drops the message and is counted in the report.

3. Handshake and versioning

The launcher sends hello {protocol:{major:1,minor:0}, cordial:"0.25.0", session, caps:{name:ver}}, listing what it can serve. The runtime answers {protocol:{major,minor}, runtime:{id,version}, client:{name,version,build}, caps:{name:ver}}, listing what it offers. The live set is the intersection, and for each capability the version is the lower of the two.
  • A minor adds optional capabilities, events and fields; receivers ignore what they do not know. A major breaks. The launcher refuses a runtime whose major it does not speak and names it.
  • A capability’s version is an integer that is additive within a major: it never changes meaning or loses a field. Taking the lower of two is therefore always safe.
  • Runtime-private events are prefixed x-<id>.. Plugins never see them; they appear only in the report.
  • A reattaching launcher sends the same hello with reattach:true, and state.get then returns what it missed.
  • Draft: cordial --runtime-check <manifest> spawns the runtime, runs the handshake and the conformance cases from cordial-protocol, and prints doctor-shaped results. The cases exist, in the crate’s conformance feature, and can be run against a runtime over any socket today; the command that wraps them does not.

4. Lifecycle

5. Capabilities, errors and what is not offered

Unsupported is never faked. A capability that is not offered is shown as unsupported in the interface, with the runtime’s name. No request for it is sent. A plugin that needs it is shown “limited on <runtime>” rather than loaded and silently dead. Replies never default to success, and a runtime emits only events its client actually produces: a declared event that nothing publishes is a lie of the same kind. Error codes: unsupported, invalid, failed, busy, and not_ready for a request before the handshake finished. An unknown request gets unsupported. An unknown event is ignored. Left out of version 1, so not specified yet: updates, doctor, session.vault, window, launch.join, and a runtime-declared settings form. The first draft had each; each is added by an ADR and a minor bump when a second runtime needs it. The development control surface (ADR-019) is not part of the protocol at all: it is a runtime’s own affair, behind its own environment variable.
  • settings keys are the same words the launch environment uses (throttle: "off", pointer_acceleration: "unlocked"), so one setting has one spelling at spawn and afterwards. Which keys are live is per runtime; ADR-044 holds today’s table for the built-in one. A Settings row for a key a runtime declares unsupported says so.
  • The resolved flags.json holds Roblox flag names only. Cordial’s own pseudo-flags (CordialGraphicsBackend, CordialFrameRateLimit, CordialDeviceProfile) are stripped by the launcher and travel as typed settings.
  • assets.overlay.root is an absolute directory the launcher has canonicalised, checked to sit inside a plugin’s install directory, and confirmed to exist. The runtime treats it as read-only and confines its reads to it.

6. What stays in the launcher

The plugin host, grants and broker, hot-swap (ADR-038), the Discord socket, notifications, URL opening, the secret store, the profile lock, the settings and report screens, the flag layer resolver and its write grants, deep-link acceptance, and aggregation of doctor output. One plugin host runs per runtime session, and it ends with the launcher; the game does not. Plugins never talk to a runtime. The launcher maps events onto the existing plugin API: game.joined and session.state to SessionState, game.presence to cordial/game.presence, spawn and exit to client.launch and client.shutdown, engine.version to cordial/engine.version, flags.apply and flags.live behind the flag write grants, assets.overlay behind asset overrides. A plugin whose capability has no backing is marked limited. Deep links. The launcher accepts a roblox-player: link, validates it, strips the launch ticket unless the profile asked to carry it, and hands the runtime the result as {join_url}. Turning that into what the engine wants is the runtime’s job (cordial_runtime::deeplink::translate today).

7. Hard limits

  • The verb set is closed per spec version. Not offered, ever: engine memory, loading code into the client, calling engine functions, executing a command, passing a descriptor, evaluating anything, a generic “set raw” or “call”.
  • Exactly one message carries a path, assets.overlay.set, and the launcher validated it. Everything else that names a file does so by convention ({session_dir}/flags.json), not by value.
  • Plugin-supplied strings reach a runtime only as typed payloads the launcher has validated: flag names and values, never paths.
  • A capability is not added because it would be convenient. Adding one needs an ADR and a spec bump (ADR-001).
  • The spec cannot police a runtime’s own process. A runtime that injects code into Roblox is outside anything a protocol can prevent. The only lever is Cordial’s listing policy (ADR-052): a runtime that injects is not listed.

8. The built-in Android runtime

cordial-run is a separate process and stays one. It serves this spec on <profile>/runtime/<session>/ctl.sock, behind the same socket a third-party runtime would use, so the launcher has one code path for it and for any other, and the built-in runtime is the conformance suite for everything above. The version-0 surface, <profile>/live/settings.sock with its set and get verbs (ADR-044), is gone: nothing in the repository used it after the launcher moved over. What it offers: lifecycle (lifecycle.stop, mapped to the quit path the window’s close button uses; the launcher’s SIGTERM after the grace lands on the same flag), events.core (game.joined, game.left, session.state, engine.version), events.presence, state and settings. What it does not: flags, assets.overlay and diagnostics, which no code behind it implements, so the handshake leaves them out and a request for one is unsupported; lifecycle.ready and health, which nothing publishes.
  • session.state is sent when a join names a user, as {signed_in:true, user_id}. The runtime learns that somebody is signed in from the join line in the engine’s log and has no source for being signed out, so it never claims it.
  • settings.get declares the ten keys the wire carries as live, and graphics, graphics_optimization_mode, present_mode, mangohud, vkbasalt and unpacked_plugins as next-launch. Those six are launch-environment settings the process reads once. They are not in the wire’s closed key set, so settings.set cannot carry them and reports one in ignored; the declaration exists so the launcher can say “next launch” from the runtime’s word.
  • The launcher adopts a runtime it did not start: on startup it looks for runtime/*/ctl.sock under every profile, says hello {reattach:true} and takes control. That is what reattaching means for a launcher process that was restarted. A launcher whose window merely closed is the same process holding the same connection, and nothing is found or needed.
  • The launch environment, the engine log the launcher reads for freeze recovery, and devctl are not part of the protocol and are unchanged.
The portable core moves to the launcher’s side of the line: plugin_host.rs, the flag layer resolver in flags.rs, roblox_api.rs. client_settings.rs, flag_reapply.rs, bloxstrap_rpc.rs and the log tail stay with the runtime, because they read the engine’s own files. The order of the move is in ADR-055; the plugin host is the next step and is not done.

9. Where the draft was ambiguous

The crate had to pick a reading, and picked the simplest. If one is wrong, the spec changes and the crate follows.
  • hello is an ordinary request (m: "hello", with an id), and the handshake answer is its reply payload. reattach is a field of the request. Any other request before it completes gets not_ready.
  • bye is an event, {"ev":"bye","n":..,"p":{"reason":".."}}, and the one event either side may send. n is the sender’s.
  • The event counter n starts at 1 and increases by one for each event sent. It is assigned when an event leaves the queue, so a dropped event consumes no number and events.dropped is how a loss is reported. A runtime keeps one counter for its life, so a reattached launcher sees the numbers carry on.
  • A frame is a request if it has m, an event if it has ev, a reply if it has ok. One of the three, never two or none.
  • The 64 KiB limit counts the line without its \n. A line that ends at end-of-file with no newline was cut off and is discarded.
  • What closes the connection and what does not. A line that is not a frame at all closes it: not UTF-8, not JSON, not an object, over the limit, none or more than one of m/ev/ok, a field of the wrong type (an id that is not a non-negative integer, a code outside the closed set). A well-formed frame carrying an out-of-range value is dropped and counted and the connection stays: a string over the bound, a typed payload that breaks its rules.
  • The 512-byte string bound applies to every string and object key in a message. The one exception is root in assets.overlay.set, which is a path and may be 4,096 bytes.
  • settings.set takes the key-to-value object itself as p, with no wrapper, and replies {applied, ignored?, notes?}. A known key with an unusable value fails the whole message with invalid; an unknown key is named in ignored and the rest apply. settings.get replies {values, declared}, where declared maps each key to live, next-launch or unsupported: that is where the per-key declaration travels. The declaration for flags ({families, allowlist}) has no stated carrier, so the crate leaves it untyped.
  • flags.live takes {flags: {name: value}} with DF* names and scalar values, and replies {applied, ignored}. flags.apply’s sha256 is 64 lowercase hex digits.
  • state.get replies with one optional field per event: game_joined, game_left, session_state, engine_version, game_presence, each absent until it has happened.
  • game.presence carries only the fields the game set: details, state, start, end, large_image_key, large_text, small_image_key, small_text. An empty string is the game clearing one.
  • diagnostics.get replies {lines: [..]}. lifecycle.stop takes {grace_ms}. Replies that carry nothing may omit p or send {}.
  • The handshake reply requires client, and runtime.id must be non-empty. A runtime must offer lifecycle, in the manifest and in the handshake.
  • Manifest placeholders expand in one left-to-right pass, so a substituted value is never expanded again; a {name} that is not one of the five is refused when the manifest is read; a known placeholder with no value ({join_url} with nothing to join) is an error, not an empty string, and the launcher decides what that means. launch.exec[0] is relative with no .., and id is a single path component of letters, digits, ., _ and -.
  • A launcher does not fight for control. A bye {reason:"superseded"} means a newer controller took over; the launcher that received it stops sending and does not reconnect, because two controllers each replacing the other is not control.
  • The runtime is asked what it runs, not assumed. A connecting launcher reads settings.get’s values as what is in force and diffs its wanted settings against that, so a value the runtime already holds is not sent again and a runtime that was started with different ones is corrected.
  • Events sent while no controller is attached are not queued. The runtime keeps the latest value of each in the snapshot state.get returns and lets the events go, and the counter n carries on across the gap. A launcher cannot know the first number it will see, so it asks state.get on every attach and does not infer what it missed from n; a gap within one connection (a number skipped) is how it learns the stream lost something.