> ## Documentation Index
> Fetch the complete documentation index at: https://cordial.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# ADR-043: The Roblox build's architecture is the binary's, and choosing another needs a second runtime

**Status:** accepted; decision 5 and the premise of decision 3 superseded by [ADR-053](/adr/ADR-053-vr-is-a-mode-of-the-android-runtime). The reasoning below is kept: it was right about a second `cordial-run` under qemu-user, which is not what the VR mode does.
**Date:** 2026-09-30
**Related:** [ADR-001](/adr/ADR-001-in-process-hooking), [ADR-033](/adr/ADR-033-roblox-versions-are-a-keyed-store), [ADR-037](/adr/ADR-037-one-lock-and-a-content-hash-for-the-build-store), [ADR-039](/adr/ADR-039-a-runtime-backend-seam-and-why-macos-waits)
**Evidence:** [`docs/analysis/roblox-build-architecture.md`](https://github.com/luohoa97/cordial/blob/main/docs/analysis/roblox-build-architecture.md)

## Decision

1. **The architecture stays compile-time.** `cordial_update::apk::HOST_ABI` is
   a `cfg` constant and Cordial installs the build for the machine it was built
   for. Nothing resolves it at launch and nothing re-derives it from what a
   mirror lists.
2. **Auto is the host, set and forget.** There is no `shell.json` key for the
   architecture, so a fresh profile has no state that could drift from the
   binary. Settings shows the value in a read-only "Roblox build" row, with
   "(this computer)" beside it.
3. **A non-host build is offered only once a route to run it has been measured
   to work**: a translator on `PATH`, a second `cordial-run` of that
   architecture with its userland, and a Vulkan device that is not a CPU
   rasteriser, with a frame rate taken under driven input. The spike found
   none. Until then a dropdown would list an option that changes nothing, which
   is a stub that lies (AGENTS.md) in interface form.
   *(Premise superseded by ADR-053, 2026-10-01: this assumed a non-host build
   needs a second `cordial-run` of its own architecture under a translator. The
   Quest build runs in the x86-64 `cordial-run` itself, under an in-process
   translator, with the host's own Vulkan driver. The rule that the Settings
   row offers no option it cannot run still holds for the phone build.)*
4. **When a release exists only for another architecture, the updater says
   so.** `Checked::newer_announced_than_obtainable` is the place. It cannot
   name the architecture today: it holds the announced major and the newest
   version this architecture can obtain, and the mirror is queried with
   `x-abis: x86_64` on both hosts, so an ARM-only release (2.737.1584,
   2.735.1138) is not seen at all. Wording that names both architectures needs
   the broad listing to be read first; that is follow-up, not part of this
   decision.
5. **Quest is rejected.** It is an arm64 Horizon OS build with dependencies
   Cordial has no answer for. *(Superseded by ADR-053: the x86-64 client runs
   the Quest build's engine in-process under a translator and answers its
   OpenXR and Vulkan calls with the host's own, measured at 90 frames/s on
   Monado's simulated HMD on the landing panel and 44 to 65 in game. Meta's
   Platform SDK, the dependency this sentence had in mind, is answered as a
   host without Meta services: every request fails, and the engine carries on.
   The phone build's architecture is still the
   binary's, as decisions 1 to 4 say.)*

## Why

The engine is loaded into `cordial-run`'s own address space and calls the host
by function pointer, so the other architecture's `libroblox.so` needs the other
architecture's `cordial-run`, GTK 4 and Vulkan loader, under a CPU translator.
**Measured 2026-09-30:** `vulkaninfo --summary` in an arm64 Fedora 44 container
under qemu-user, `/dev/dri` mapped, lists a single device, llvmpipe
(`PHYSICAL_DEVICE_TYPE_CPU`), so an arm64 client on an x86\_64 host would render
on an emulated CPU. The spike does not separate a missing Intel driver in
Fedora's aarch64 Mesa from one that cannot reach the GPU through qemu-user;
either way there is no GPU device. FEX and box64 could not be tried without
arm64 hardware. The store is also keyed by version alone, so two architectures at one
version would collide.

*Corrected 2026-10-01:* the first sentence of this section is true of a whole
foreign `cordial-run` and was taken to be the only route. It is not. The
engine's imports can be answered across the architecture boundary by generated
thunks: the x86-64 `cordial-run` links the Quest build's arm64 `libroblox.so`,
runs it under dynarmic and hands its Vulkan and OpenXR calls to the host's own
driver and runtime ([ADR-053](/adr/ADR-053-vr-is-a-mode-of-the-android-runtime),
[design §9.5](https://github.com/luohoa97/cordial/blob/main/docs/vr/dynarmic-design.md)). The qemu-user measurement above stands
for what it measured.

## Consequences

If a route is ever measured to work, this ADR is superseded: the store becomes
keyed by ABI and version, `HOST_ABI` and its neighbours become a value passed
down, and the row becomes a dropdown that lists only what a detected translator
can run. Until then the row is honest and free.

*2026-10-01:* for the Quest build that has now happened in part. ADR-053 keys
the store by ABI and version (`builds/arm64-v8a/<version>/`), and the Quest
build is reached through Settings → VR (which holds "Play in VR"), not through this row.
`HOST_ABI` and the read-only row are unchanged, because nothing translates the
phone build.

**Reopen when** a qemu-user or FEX run shows a hardware Vulkan device and a
measured frame rate. The measurements still owed are listed in section 7 of the
spike.

## Notes moved from docs/multiarch.md (2026-10-02)

The page `docs/multiarch.md` began life as the decision record for "Task B (§16.3)" and had accumulated corrections and a status log. It was rewritten as a short page for readers, and what it held is kept below verbatim, with its headings demoted. Later dated corrections in it (2026-10-01) are the ones already summarised in the status header above and in ADR-053.

### Decision

**Execute natively when the host ABI matches an ABI the APK ships. Do not build a
translation layer.**

Task A established that Roblox ships a complete x86-64 Android build (see
[`findings.md`](https://github.com/luohoa97/cordial/blob/main/docs/findings.md) §1). Cordial therefore never translates machine code. This
is a build-flag and runtime-dispatch concern, not an architectural one.

*Corrected 2026-10-01:* "never translates machine code" is now true of the phone build
only. The VR mode runs the Meta Quest build's arm64 engine under dynarmic inside the
x86-64 `cordial-run` ([ADR-053](/adr/ADR-053-vr-is-a-mode-of-the-android-runtime)).
The table below still describes how the phone build is chosen and run.

| Host | APK ships a matching ABI | Strategy | Phase |
| - | - | - | - |
| x86-64 | `lib/x86_64/` — yes | Native execution + CPU *feature* emulation (`libbadcpu`) | 1 |
| ARM64 | `lib/arm64-v8a/` — yes | Native execution of `arm64-v8a`; no feature emulator needed | later |
| ARM64 | absent | Translation required | **not supported** |
| anything else | — | — | not supported |

### Consequences

**x86-64 is the only supported target for Phases 1–2.** Everything else is deferred until
the runtime actually launches Roblox on the primary target.

**ARM64 hosts are cheap in principle and expensive in practice.** The ABI is present in
the APK, so the loader, bionic shim, syscall translation and framework layer are all
architecture-agnostic in design. Treat ARM64 as a real port, not a compile flag, and do
not attempt it until x86-64 works. `libbadcpu` is not built on ARM64: it is an x86-64
instruction emulator and has no meaning there.

**The reason has changed, though, and the sentence above used to give the wrong one.** It
said each layer "contains architecture-specific code (TLS layout, relocation types,
syscall numbers, signal frame layout)". Inventoried on 2026-08-26: **ours contains none of
it.** `git grep -E '__x86_64__|__aarch64__|asm volatile|target_arch' -- crates native`
matches nothing across all 159 tracked files — no TLS handling, no relocation switch, no
syscall wrapper, no signal trampoline, no assembly. All of that is upstream bionic's, and
upstream already has it per-architecture: `third_party/mcpelauncher-linker/CMakeLists.txt`
carries an arm64 branch, the arch trees are checked out, and `GetTargetElfMachine()`
already returns `EM_AARCH64` on an aarch64 build, so a wrong-architecture library is
refused with a readable message for free.

**What is expensive is a thing this document did not anticipate: the page size.**
`third_party/mcpelauncher-linker/include/compat.h` defines `PAGE_SIZE` as a literal 4096
and force-includes it into every linker translation unit. Roughly half the hardware people
ask about is not 4K — Asahi on Apple silicon is 16K by hardware mandate, and recent
Raspberry Pi OS defaults the Pi 5 to a 16K kernel, while most postmarketOS phone SoCs and
Graviton are 4K. Upstream *does* detect this at run time and switch strategy, and the
strategy it switches to is the problem: it maps the whole reservation `PROT_READ |
PROT_WRITE | PROT_EXEC` and copies the ELF in by hand, never re-protecting it.

**That silently voids ADR-001's guarantee.** `patches/0001-map-engine-text-read-only.patch`
edits an `mmap64` the 16K path never reaches, so the patch is not broken by this — it is
bypassed, and 106 MB of engine text stays `rwxp` for the life of the process. ADR-001's
verification is also scoped to "Roblox's own x86-64 build"; the arm64-v8a build's
`DT_TEXTREL` status has never been looked at.

So the honest split is: **a 4K aarch64 host is plausibly a mechanical job, and a 16K one is
not** — and two of the three platforms people ask for are 16K. Android 15 also requires
16 KB-aligned native libraries, so an arm64-v8a `libroblox.so` will likely carry
`p_align = 16384` and take a different loader path from the x86-64 one *even on a 4K host*.
The port cannot assume 4K aarch64 behaves like x86-64.

**The cheapest next step needs no ARM hardware at all:** `readelf` an `arm64-v8a`
`libroblox.so` and read off its `DT_TEXTREL`, `p_align`, relocation counts and CPU feature
floor. That closes a real slice of the unknown for the price of one APK.

**No translation layer will be designed.** If a future host has no matching ABI, the
answer is "unsupported", not "write a JIT".

*Superseded for one build, 2026-10-01:* the Meta Quest build, which ships only
`arm64-v8a`, runs on x86-64 under an in-process translator for the VR mode
([ADR-053](/adr/ADR-053-vr-is-a-mode-of-the-android-runtime)). The phone build is
still executed natively and never translated.

**Reopened and re-measured, 2026-09-30.** The last sentence here used to say reopening
this required reopening Task A. The spike in
[`analysis/roblox-build-architecture.md`](https://github.com/luohoa97/cordial/blob/main/docs/analysis/roblox-build-architecture.md) is that
reopening, and it changes the reasoning without changing the answer: choosing the other
architecture's `libroblox.so` is the easy half, and running it needs a second
`cordial-run` with its whole userland under a CPU translator, because the engine is
loaded into `cordial-run`'s own address space. No route that reaches the GPU was found
(qemu-user's was measured: `vulkaninfo` in an arm64 container lists only llvmpipe, a
CPU device; FEX and box64 need arm64 hardware nobody here has). Settings therefore shows a read-only "Roblox build" row and
no dropdown; [ADR-043](/adr/ADR-043-the-roblox-build-is-the-binarys-architecture)
records the decision and what would reopen it.

*Corrected 2026-10-01:* "needs a second `cordial-run`" was wrong as a general claim. The
in-process load does not force it: the x86-64 `cordial-run` links the Quest build's arm64
engine and answers its imports through generated thunks, so the engine reaches the host's
own Vulkan driver (an RTX 4070 Ti, not llvmpipe) and OpenXR runtime
([`vr/dynarmic-design.md`](https://github.com/luohoa97/cordial/blob/main/docs/vr/dynarmic-design.md) §9.4, §9.5). What the spike measured
about qemu-user is unaffected.

**No Quest/VR target.** The Quest build ships no x86 code, so supporting it would mandate
exactly the translation path this decision exists to avoid. Linux desktop only.

*Superseded 2026-10-01:* the first sentence was right and the conclusion was not. The
Quest build does ship only `arm64-v8a`, and it is now a VR target on x86-64 Linux, through
that translation path, with the headset as a display over an OpenXR runtime
([ADR-053](/adr/ADR-053-vr-is-a-mode-of-the-android-runtime),
[`vr.md`](/vr)). Measured on Monado's simulated HMD only: 90 frames/s at 90 Hz on the
landing panel, 44 to 65 in game. Nothing has been measured on WiVRn or a real headset.

### Status, 2026-09-23

The x86\_64/aarch64 split this document called for in `apk.rs`/`install.rs` was real
but incomplete: it stopped at the two constants and never reached the code that
actually *fetches* a build. `crates/cordial-update/src/provider/mirror.rs` hardcoded
`ABI_EXACT = "x86_64"` and searched archives for the literal path
`lib/x86_64/libroblox.so` rather than `apk::LIBRARY_IN_APK`; `provider/local.rs` and
`cordial-shell/src/install.rs` had the same hardcoding for the engine path, the split
APK filename, and Sober's own package directory. An aarch64 build would have compiled
clean and then found no build to run, silently, because the acquisition layer never
asked for or recognised anything but x86\_64. Fixed 2026-09-23; see the commits
touching `provider/mirror.rs`, `provider/local.rs`, `cordial-shell/src/install.rs`,
`crates/cordial-update/src/deno.rs` and `justfile`.

One of those fixes was a genuine latent bug rather than a missing branch:
`cordial-shell/src/install.rs` rebuilt the split APK's filename from `HOST_ABI`
(`arm64-v8a`, hyphenated) instead of using `cordial_update::install::SPLIT_APK`
(`arm64_v8a`, underscored, which is how Play actually spells it). That is invisible on
x86\_64, which has no hyphen to get wrong, and would have made this code path never find
the split archive on a real aarch64 install.

Packaging gates (`ExclusiveArch: x86_64` in `packaging/rpm/cordial.spec`,
`Architecture: amd64` in `packaging/deb/control.in`, the AppImage's x86\_64-linux-gnu
WebKitGTK discovery) were the same shape of gap and are fixed in the same batch of
commits, alongside aarch64 legs in `release.yml`/`flatpak.yml`/`test.yml`/`apt.yml`/
`yum.yml` — the pacman/AUR arch job stays x86\_64-only, since Arch Linux ships no
aarch64 build at all (Arch Linux ARM is a separate project with its own repos).

**Corrected once more, 2026-09-24: the mirror fix above was itself wrong in a way
worth recording.** Making `ABI_EXACT` (`provider/mirror.rs`) equal to `apk::HOST_ABI`
made an aarch64 build ask APKPure for `x-abis: arm64-v8a` specifically — and for that
filter APKPure serves XAPK split bundles (`config.arm64_v8a.apk` nested inside a zip
this reader cannot look inside), not the monolithic all-ABI APK that `x-abis: x86_64`
returns and that this module's own header already measured. `ABI_EXACT` is now pinned
to the literal `"x86_64"` unconditionally, on both architectures — it selects
APKPure's bundle shape, not "the caller's own ABI" — with `apk::LIBRARY_IN_APK`
downstream still doing the real, correct, per-architecture check inside whatever comes
back. Known and documented gap: an ARM-only release newer than the current x86\_64 one
(this already happened once, 2.735.1138, `docs/analysis/apk-mirrors.md`) would not
surface as "newest" on an aarch64 host. Real XAPK support in `held()`/`classify()`
would close that properly; not attempted, since it needs its own synthetic-XAPK tests.

**`pthread_mutex_t`/`pthread_attr_t` needed a real ABI shim, found by building, not by
inspection.** `crates/cordial-runtime/src/bionic/pthread.rs`'s size table was only
ever measured on x86\_64, where bionic and glibc happen to agree on both types'
sizes. On aarch64 they do not (glibc's `pthread_mutex_t` is 48 bytes against bionic's
40; `pthread_attr_t` 64 against 56) — handing either straight to glibc, as the
pre-existing x86\_64-only passthrough did, would have overrun the engine's own
allocation on the first mutex it locked. Both are now wrapped the same way `sem_t`
already was, aarch64-only; x86\_64 is untouched.

**The page-size risk and ADR-001's re-verification are still open** — nothing here
closes either, and qemu-user emulation (4K-page x86\_64 emulating 4K-page aarch64)
says nothing about real 16K-page hardware. What the "cheapest next step" paragraph
below asked for is now half-done rather than not-done: a real, Roblox-signed
arm64-v8a `libroblox.so` was fetched (via `cordial_update`'s own mirror path,
signature verified, fingerprint matches this module's own documented one) and loaded
under emulation (a one-off session; **no command line for it was kept, so
it is not repeatable from this document** -- the spike's section 7 lists what a repeat
needs) — `cordial-run` got through the bionic linker, `JNI_OnLoad`, GameActivity
native init, and the engine's own flag initialisation (139 flags, by name) before the
smoke test's time bound ended, with no crash and no undefined symbol. The specific
`readelf --dyn-syms`/`DT_TEXTREL`/`p_align` check this paragraph asks for was not
run against that binary before it was deleted (per the disposable-profile instructions
for that test) — still open, and still cheap: needs one more fetch, not a device.

### Implementation notes

* Host ABI is fixed at **compile time**, not resolved at launch. `cordial_update::apk::
  HOST_ABI` is a `#[cfg(target_arch)]` constant, and `LIBRARY_IN_APK` and `SPLIT_APK` are
  spelled per-architecture beside it; an unsupported target fails with a `compile_error!`
  naming this document. Cordial never translates the phone build's machine code, so the
  only phone library it can load is the one for its own architecture (*corrected
  2026-10-01:* this said "never translates machine code"; the Quest build is translated,
  ADR-053, and is stored apart under `builds/arm64-v8a/`) — which makes the ABI a property of the binary
  rather than something to detect. This paragraph previously described a run-time
  resolution that did not exist; the constants were two hardcoded literals in two crates.
* Watch the two spellings. The directory inside the APK is `lib/arm64-v8a/` with a hyphen;
  Play's split archive for the same ABI is `split_config.arm64_v8a.apk` with an underscore.
* Prefer the APK variant that carries the host ABI. If only a universal APK is available,
  load from the matching subdirectory and ignore the others. `apk::holds_with(apk, wanted)`
  already takes the path as a parameter and `engine_candidates` already loops over
  siblings, so this part needed no change.
* The engine cache is ABI-named — `~/.cache/cordial/lib/<abi>` — so two builds for two
  architectures against one home directory cannot overwrite each other.
* `libbadcpu` is gated on `CMAKE_SYSTEM_PROCESSOR` in `native/CMakeLists.txt`, and its
  `SIGILL` handler is installed only in the Roblox child process. It was **not** gated
  until 2026-08-26, and this line claimed a Meson `host_machine.cpu_family()` check in a
  project that uses CMake. `cpuid.cpp` includes `<cpuid.h>`, which does not exist on
  aarch64, so it was the first thing an ARM build would have hit. Nothing links the
  archive today, so gating it cost nothing.
* x86-64-v2 is the effective CPU floor. Below SSE4.1 the emulator cannot help enough and
  the correct behaviour is a clear hardware-too-old message, not a crash.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.