Skip to main content
Status: proposed Related: ADR-003, ADR-007, ADR-010, ADR-013, ADR-014, ADR-020

Decision

There is one installable thing in Cordial and it is a plugin. A texture pack is a plugin. A flag preset is a plugin. A Discord presence integration is a plugin. There is no second import path, no “mods” folder beside the plugins folder, no separate list in Settings, and no type key in plugin.json saying which sort of thing this is. Whether a plugin contains code is a property read off its manifest, not a category it declares. A manifest with no entry has nothing to run; a manifest with no capabilities can affect nothing outside its own directory. A plugin that is both is data — assets, flags, preferences — and there is nothing to sandbox because there is nothing to execute. entry therefore becomes optional, and its absence is the whole signal, in exactly the way ADR-020 made the preferences declaration the whole signal that a plugin has a settings page. This decision covers five things that turned out to be one thing:
  1. asset overlays resolved by interception, on both routes the engine uses;
  2. orphan detection, in two flavours that mean different things;
  3. precedence displayed rather than implied;
  4. consent at install, gated on the plugin containing code at all;
  5. hot reload as a development switch, off by default.

What was measured first, and what it changed

Three questions were settled by observation before any of the above was designed. Two of them killed a hazard outright and one of them refuted a premise this ADR was originally briefed with.

The APK is never mapped, so an intercept is not defeated by offsets

The hazard is real in general: if the client mmaps base.apk once and reads each asset as bytes at an offset inside that single mapping, then interposing on open buys nothing, because no asset ever gets an fd of its own. It does not happen here. /proc/<pid>/maps and /proc/<pid>/fd were sampled every 400 ms across two complete cold launches of cordial-run (--profile CordialTest --host-libc --game-activity --join-url roblox://experiences/start), 136 samples, with libroblox.so present in the mapping list in every one of them so it is certain the engine was loaded throughout:
Zero mappings of base.apk. Zero file descriptors on it. Zero mappings of ~/.cache/cordial/assets, the extracted content tree. A mmap-by-offset scheme requires a mapping that persists for the life of the reads; there was never one to find. This is passive /proc reading of another agent’s running client, not a launch and not a debugger attach. It is a negative result sampled discretely and therefore cannot exclude a mapping created and destroyed inside a 400 ms window; what it does exclude, conclusively, is the long-lived whole-archive mapping the hazard depends on. Marked INFERRED only in that narrow sense. The AAssetManager route needs no measurement at all, because that route is ours: android/asset.rs reads the zip entry with the zip crate, decompresses into a Vec, leaks it and hands the engine an interior pointer. AAsset_openFileDescriptor hands back a sealed memfd. The engine cannot map the APK through an API whose implementation never opens it.

The engine imports no openat, so the dirfd hazard does not exist here

The second classic intercept bug — a relative path resolved against a directory fd, bypassing a resolver that only understands absolute paths — is answered by the dynamic symbol table rather than by argument. Every path-taking or size-reporting libc symbol libroblox.so imports:
No openat. No fstatat, no statx, no open64, no __xstat. There is no dirfd-relative call in the engine’s import list to bypass anything, so canonicalisation has one entry point by construction rather than by discipline. This is a fact about this build and it must be re-checked when the build moves. docs/analysis/undefined-symbols.tsv already generates the stub table from exactly this list, so the check costs one comm, and the machinery in AGENTS.md’s “Missing symbols” section is the machinery for it.

fstat needs no resolver, and this is a real result rather than an omission

fstat is imported and is not in native/system_paths.cpp’s intercept table. The received wisdom is that this is the bug that eats the week: intercept open but not the size call, and the client allocates for the original file’s size and reads a larger overlay into it. That failure needs the size and the bytes to come from different files. It cannot happen through fstat, because fstat takes an fd — the fd our open already returned, pointing at the overlay file itself. It reports the overlay’s size because it is looking at the overlay. Leaving fstat alone is not an oversight to fix; intercepting it would mean intercepting a call that is already correct. The hazard is entirely in the path-taking size and existence calls, and those are stat, lstat and access — all three of which are already in the intercept table, alongside open, fopen, opendir, realpath and readlink. So the invariant has a precise statement:
Every path-taking call in cordial_system_symbols’s table consults one resolver, or none of them do. fstat is exempt because it is not path-taking. mmap is exempt for the same reason.
Eight functions, one table, one resolver, and the table is the checklist. A symbol added to that table without being routed through the resolver is the regression to guard against, and the test for it compares the two lists rather than trusting a reviewer.

Bloxstrap mods are only partly portable, and the brief overstated it

The premise handed to this work was that the Android APK’s tree is assets/content/{textures,sounds,fonts,sky,models,avatar,…}, that a Bloxstrap mod mirrors the Windows client’s content/…, and that the two are therefore the same subtree under a different prefix. Checked against the actual APK on this host — 1835 entries under assets/ — that is true of the prefix and not true of the contents. The tree is bigger than the premise: A mod built against Windows content/… addresses at most the first row. The 485 UI textures under ExtraContent/textures/ui are invisible to it, and ExtraContent is not a Windows concept. Filenames diverge inside the matching directories too. Probing the canonical Bloxstrap mod targets against the real archive: So cursor mods and font mods drop in unchanged, and the single most famous Bloxstrap mod in existence — replacing the death sound — silently does nothing, because it ships ouch.ogg and this build reads oof.ogg. That is the finding that makes orphan detection load-bearing rather than a nicety. Without it, “Bloxstrap mods work in Cordial” is a claim that is two-thirds true and fails in the case users will try first, with no error anywhere. Cordial must not advertise Bloxstrap compatibility without shipping the report that says which files landed. The two are one feature.

The instrument comes first

CORDIAL_TRACE_ASSETS=1 already printed one line per lookup to stderr. It is promoted here to the primary instrument, because one cold launch plus one game join yields the ground-truth list of every asset path this build actually asks for — which is the missing half of the Windows-to-Android mapping table above, and is derived rather than guessed.
The recorder is separate from the stderr tracing and always on, because it costs one hash insert per distinct name and answers questions the log cannot: it is a set, it survives to the end of the run, and it is what both orphan signals are computed against. AGENTS.md’s rule about paying for nothing is satisfied by it being a set rather than a log — a name already seen costs a lookup and no allocation. This ordering is deliberate and is this project’s standing rule. Every ad-hoc score taken here before its instrument was checked against a control turned out to be constant across all runs.

Mechanism

One resolver, two routes

Route 1, AAssetManager — Cordial’s own code, already the mechanism ADR-010 describes, and the route that carries textures, sounds, fonts and models. This is covered, in Rust, in android/asset.rs. Route 2, the real filesystem — the engine is handed setAssetFolder <cache>/assets/content and reads through libc. This is the route ADR-010 explicitly left out of scope, and it is what serves anything the engine resolves by path rather than by asset name — ssl/cacert.pem being the one already known to matter. The two routes share one resolver and one index. They must, or an overlay applies to a texture and not to the same texture reached the other way, which is a bug nobody would guess from the symptom. The resolver is Rust, in android/asset.rs; the libc route reaches it through a C ABI entry point that native/system_paths.cpp calls from the eight path-taking functions already in its table. A path arriving on route 2 is turned into an asset-relative name by stripping the extracted-assets prefix, and only then looked up. A path outside that prefix is not an asset and is forwarded untouched — the overlay must never become a general filesystem redirect, which is the same line ADR-007 draws between an effect and a channel.

Writes are refused, and this is decided now rather than discovered later

O_WRONLY, O_RDWR, O_CREAT, O_TRUNC or O_APPEND against a path that resolves to an overlay file do not get the overlay. They are forwarded to the real path underneath, untouched. The three candidate behaviours were: deny the open, copy-on-write into the instance root, or pass through. Pass through wins because the overlay is read-only by definition — ADR-010’s entire claim is that nothing is ever written into the APK or into anything extracted from it, and handing a writable fd to a plugin’s file would make an overlay a place the engine can scribble, which is neither non-destructive nor something the plugin author consented to. Denying would break any engine write to a path that merely happens to collide with an overlay name. Copy-on-write needs a manifest of what was copied and becomes exactly the half-restored-tree problem ADR-010 declined to build. So: reads resolve to the overlay, writes go to the original. Stated in the resolver, tested, and written here so nobody has to rediscover it from a corrupted cache.

The index, built once

Resolution today canonicalises and stats every layer on every lookup. That is a stat storm on the hottest path in asset loading, and it gets worse with each overlay installed. Instead: each root is walked once, producing a map from asset-relative name to the winning layer, with the layers merged in precedence order at build time so a lookup is one hash probe. The index is held behind an Arc and swapped wholesale; a lookup clones the Arc and never blocks on a writer. A name absent from the map is absent from every overlay — that is the negative answer, and it costs the same probe, so there is no separate negative cache to keep coherent. Precedence is unchanged from ADR-010 and from flags.rs: the user’s root beats every plugin’s, and among plugins the last registered wins. Building the index in that order means the map already holds the winner and the resolver has no ordering logic in it at all.

A shipped overlay directory needs no capability

This fell out of implementation and is the piece that makes “a texture pack is a plugin” true rather than aspirational, so it belongs in the decision rather than in a comment. A plugin’s overlay/ directory is registered by Cordial when the plugin is installed and enabled. It needs no assets.override grant, and this is not a hole — it is the precedent flags.rs already set. flags::collect reads every enabled plugin’s own flags.json with no capability check at all, because a static file a plugin ships is not a request a process is making. It is what the plugin is, and installing and enabling it is the consent. assets.override gates something genuinely different: a running plugin asking Cordial to register a directory of its choosing at runtime. That is a request from a process and is brokered like every other one, per ADR-007. Confusing the two would mean a texture pack that cannot work without a permission prompt for a process it does not have — and would put us back to needing a second import path for the thing that has no code, which is the decision this ADR exists to avoid. It also means the consent rules hold together: a data-only plugin declares no capabilities, so it gets no prompt, so the prompt keeps its meaning. The precedence rule is flags::collect’s too, including its refusal to let a user plugin shadow a first-party id: system root first, user root second, sorted within each. Registration order is precedence order among plugins, so the shadow report is quoting a fact rather than a filesystem accident.

Two orphan signals, which mean different things

Stale — an overlay path with no counterpart anywhere in the APK’s asset tree. Computed by diffing the index against the archive’s entry list; needs no running client and is the check to run at install time and after a Roblox update.
retro-ui: 7 files no longer match anything in client 2.7xx
Unrequested — an overlay path that does exist in the APK but that the engine never asked for during a session. Computed by diffing the index against the recorder’s set. These are not the same claim and must not be reported as one. Stale means this file can never apply: the name is wrong, or the build removed it — this is the signal that catches ouch.ogg. Unrequested means this file did not apply today: possibly because the feature it decorates was never opened, possibly because the session was short. Stale is a defect; unrequested is an observation, and the report says so in those terms rather than presenting a count. Unrequested carries an honest caveat that is stated wherever it is shown: a 60-second launch that never joins an experience will call almost everything unrequested. It is a strong signal after a real session and a misleading one after a smoke test.

Shadowing is shown, never implied

When two layers provide the same name the loser is recorded next to the winner, and the report names both:
This falls out of building the index in precedence order for free, and it is the answer to “why did this file not change” — which is otherwise indistinguishable from “the overlay is broken”, and is the question that will be asked most.

Hot reload: a development switch, off by default

Interception makes reload cheap: rebuild the index and swap the Arc. Nothing is remounted and nothing is copied. It is off by default and enabled with CORDIAL_OVERLAY_WATCH=1, for the reason this codebase has a standing rule about: watching a directory tree costs wakeups, and a texture pack that nobody is editing must not cost a single one. The default reload is explicit — a call, from the Settings page or the development MCP — and the watcher is what an author turns on while iterating. Two caveats that are honest limitations and are documented rather than papered over: A reload affects the next load, not what is on screen. Manager::read caches decompressed bytes for the life of the process and hands the engine an interior pointer that must stay valid; ADR-010 already records this. Swapping the index does not retroactively change what a held pointer points at. A reload therefore applies to assets loaded after it, which for most of the interesting ones means the next launch. Saying “reloaded” and showing the old texture is precisely the stub-that-lies failure AGENTS.md forbids, so the reload reports how many cached names it could not affect. The watcher is a development instrument and is not load-bearing. If it misses an event the fix is to press reload. Nothing in Cordial’s correctness depends on it firing.

What the implementation settled that the design did not

The stale check runs without a client. cordial-run --check-overlays sets the APK, registers the overlay stack, diffs it against the archive’s entry list and exits before the engine is loaded. That matters more than convenience: it is the only orphan signal that can be produced without playing, and it is the one that is a defect rather than an observation. The build is named by scanning, and says nothing rather than guessing. “7 files no longer match anything” is not a useful sentence without a version beside it, because the user’s next question is always “since when”. The version is read by scanning the APK’s binary AndroidManifest.xml string pool for a token shaped like a Roblox version — three dotted numbers with a three-digit middle — rather than by writing an AXML parser for one field. The narrow shape is deliberate: a looser pattern picks an AndroidX library version out of the same pool and reports it as the client’s, and a confidently wrong version is worse than none. An ambiguous scan falls back to naming the archive. The watcher polls. An inotify watch must be re-armed per directory as subdirectories appear, and getting that wrong presents as a reload that silently stops working. A signature over the roots — file count and newest mtime — taken twice a second while an author is editing is small enough not to matter and simple enough to be obviously right. It misses an edit that restores the file’s timestamp, and the answer to that is the explicit reload, which is the default anyway. A prompt saying “this plugin wants to run code, allow?” is worse than no prompt. It appears for everything, it is answered yes by everyone, and it trains the user to dismiss the one that mattered. ADR-007 already supplies the better vocabulary: capabilities are named effects, and an effect is something a person can actually judge. Three rules. 1. A plugin with no code gets no prompt at all. No entry, nothing to run; no capabilities, nothing it could reach. A texture pack installs silently, the same way copying a file into a folder does, because there is nothing to ask about and asking anyway is what makes the third prompt meaningless. This is the direct payoff of “code is a property, not a category” — the prompt is gated on reading the manifest, not on which import button was pressed. 2. Code starts disabled regardless. The prompt is not the gate; the toggle is. Consent and enablement are separate acts, and enablement.rs already argues this in the other direction — that disabling must not cost the approvals. The same separation run forwards means approving what a plugin may do does not start it doing it. A user who clicks through a prompt has still not started anything. This is a change to the current install path, and it is a correction rather than an addition. plan_confirmation_text today tells the user that installing “grants each plugin below exactly what it requests”, and the handler then does so. Combined with enablement’s “absence means enabled”, a marketplace install today produces a plugin that is granted and running the moment the dialog closes. That is one act, not two, and it contradicts both this ADR and the spirit of ADR-003’s default deny. An installed plugin with code is written into plugin-enabled.json as false, explicitly, and the user turns it on. 3. flags.write is spelled out honestly. ADR-020 records what it actually is, and the wording it deserves follows from that record rather than from the capability’s name:
Change how Cordial itself renders and behaves. Sets Roblox FastFlags, and also Cordial’s own settings including the graphics backend (CordialGraphicsBackend) and present mode (CordialPresentMode). Takes effect at the next launch. Your own choices in Settings still win.
“Change some Roblox settings” would be technically true and materially misleading, which is the standard webview_policy.rs already holds a URL to. Every capability gets a sentence of this kind — what it does, in the second person, with the honest edge of it included and its limit stated. The sentences live beside the Capability enum so a variant cannot be added without one, in exactly the way name/parse/all are already held together by a test. assets.override gets the sentence ADR-010’s “what is still refused” section earned: replacing a mesh is not a cosmetic change, and the user is the one deciding.

Built-in plugins: one question, when it is due (2026-10-05)

The three rules above are about an install. A built-in has no install click, so its first-appearance question was asked by each plugin’s row as the Settings window was built. A fresh profile opening Settings on any tab met one dialog per built-in stacked over it, four of them, including fps-flex, which ships off. That is the third-prompt failure rule 1 exists to prevent, with a fourth prompt. The rule now: the question is put only (a) while the Plugins page is the one on screen and Use Plugins is on, (b) about built-ins that are switched on and have something requested but not yet granted (consent::builtin_due), and (c) as a single dialog for however many are due. A built-in that ships off is asked when it is switched on, because that is when the answer starts to matter. Declining or dismissing grants nothing, and a profile is asked about a plugin once. Rejected: shipping the first-party plugins’ capabilities granted. ADR-006 makes first-party plugins subject to the capability model, and presence.set is an outbound broadcast to other people, which is not the category of SHIPS_DISABLED’s concern. Nothing in ADR-003 or ADR-007 grants first-party code anything by default, so a plugin still gets a capability only when the user has said yes. Rejected: one dialog per plugin, queued. It removes the stack and keeps the run: three prompts in a row is answered by reflex. The cost of the single dialog is that Allow covers every plugin it lists; the dialog says so, and says how to allow only some.

Consequences

Accepted: entry becomes optional in plugin.json, and a manifest with neither entry nor capabilities is a valid, complete plugin. Every existing manifest is unaffected, because an absent optional key is exactly what #[serde(default)] already does for capabilities, version and preferences. Accepted: a plugin can grow code later without changing what it is. A texture pack that adds an entry in version 2 becomes a plugin with code, gets a consent prompt at that upgrade, and starts disabled. The identity, the directory, the id and the user’s other settings all survive, because none of them ever encoded the distinction. Accepted: one list in Settings, which will be long. A user with thirty texture packs and two integrations sees thirty-two rows. That is the honest presentation of what they installed, and filtering is a UI affordance to add later rather than a reason for a second list — two lists is how the two import paths grow back. Accepted: the libc route makes Cordial’s resolver load-bearing for ssl/cacert.pem. A resolver bug there does not produce a wrong texture, it produces a TLS failure three layers from anything mentioning certificates — which is a failure this project has already paid for once. The prefix check therefore fails closed to the original path: anything the resolver is not certain about is forwarded untouched. Not yet wired, and stated rather than implied: the filesystem route’s resolver, its write rule and its C ABI entry point (cordial_overlay_resolve) are implemented and tested, and native/system_paths.cpp does not call them yet. Its table holds stat, lstat, access, opendir, realpath, readlink, fopen, statvfs and open, and all of them must be routed together — the invariant above is that they consult one resolver or none of them do. Until that change lands, only the AAssetManager route is live, which is what covers the textures, sounds, models and fonts people ask to override. Rejected: overlayfs. The assets are zip entries inside base.apk, so there is nothing on a filesystem to overlay without extracting the whole archive first; and Flatpak cannot mount overlayfs unprivileged in any case. Interception also gives the trace, the orphan signals and the shadow report, none of which a mount can provide. Rejected: a type: "assets" manifest key. It is the category this ADR exists to not have. Two facts that can disagree eventually do, and here the disagreement would be a data-only plugin that declares itself code, or an entry that never runs because a key says it should not.

What would change this

Evidence that the engine reaches assets by a route neither covered here — a mapping of the archive taken and released inside a sampling window, or a build that starts importing openat — would not change the decision but would change the mechanism, and the mechanism section would need rewriting rather than patching. Evidence that people look for a texture pack somewhere other than the plugin list would be an argument about presentation, not about whether the two things are one kind.